feat: Refactor storage adapter and mount handling; migrate mounts to storage adapters; enhance SideNav; implement database migration scripts

This commit is contained in:
shiyu
2025-08-28 12:59:24 +08:00
parent bfa8898931
commit 62a1c5810d
16 changed files with 163 additions and 262 deletions
+1 -1
View File
@@ -5,5 +5,5 @@ __pycache__/
.venv/ .venv/
.vscode/ .vscode/
data/ data/
migrate/
.env .env
+3 -1
View File
@@ -13,11 +13,13 @@ FROM python:3.13-slim
WORKDIR /app WORKDIR /app
RUN apt-get update && apt-get install -y nginx && rm -rf /var/lib/apt/lists/* RUN apt-get update && apt-get install -y nginx git && rm -rf /var/lib/apt/lists/*
COPY requirements.txt . COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt && pip install gunicorn RUN pip install --no-cache-dir -r requirements.txt && pip install gunicorn
RUN git clone https://github.com/DrizzleTime/FoxelUpgrade /app/migrate
COPY --from=frontend-builder /app/web/dist /app/web/dist COPY --from=frontend-builder /app/web/dist /app/web/dist
COPY . . COPY . .
+1 -2
View File
@@ -1,13 +1,12 @@
from fastapi import FastAPI from fastapi import FastAPI
from .routes import adapters, virtual_fs, mounts, auth, config, processors, tasks, logs, share, backup, search from .routes import adapters, virtual_fs, auth, config, processors, tasks, logs, share, backup, search
def include_routers(app: FastAPI): def include_routers(app: FastAPI):
app.include_router(adapters.router) app.include_router(adapters.router)
app.include_router(virtual_fs.router) app.include_router(virtual_fs.router)
app.include_router(search.router) app.include_router(search.router)
app.include_router(mounts.router)
app.include_router(auth.router) app.include_router(auth.router)
app.include_router(config.router) app.include_router(config.router)
app.include_router(processors.router) app.include_router(processors.router)
+22 -52
View File
@@ -2,7 +2,7 @@ from fastapi import APIRouter, HTTPException, Depends
from tortoise.transactions import in_transaction from tortoise.transactions import in_transaction
from typing import Annotated from typing import Annotated
from models import StorageAdapter, Mount from models import StorageAdapter
from schemas import AdapterCreate, AdapterOut from schemas import AdapterCreate, AdapterOut
from services.auth import get_current_active_user, User from services.auth import get_current_active_user, User
from services.adapters.registry import runtime_registry, get_config_schemas from services.adapters.registry import runtime_registry, get_config_schemas
@@ -39,26 +39,21 @@ async def create_adapter(
data: AdapterCreate, data: AdapterCreate,
current_user: Annotated[User, Depends(get_current_active_user)] current_user: Annotated[User, Depends(get_current_active_user)]
): ):
norm_path = AdapterCreate.normalize_mount_path(data.mount_path)
exists = await StorageAdapter.get_or_none(path=norm_path)
if exists:
raise HTTPException(400, detail="Mount path already exists")
adapter_fields = { adapter_fields = {
"name": data.name, "name": data.name,
"type": data.type, "type": data.type,
"config": validate_and_normalize_config(data.type, data.config or {}), "config": validate_and_normalize_config(data.type, data.config or {}),
"enabled": data.enabled, "enabled": data.enabled,
"path": norm_path,
"sub_path": data.sub_path,
} }
norm_path = AdapterCreate.normalize_mount_path(data.mount_path)
exists = await Mount.get_or_none(path=norm_path) rec = await StorageAdapter.create(**adapter_fields)
if exists:
raise HTTPException(400, detail="Mount path already exists")
async with in_transaction():
rec = await StorageAdapter.create(**adapter_fields)
await Mount.create(
path=norm_path,
sub_path=data.sub_path,
adapter=rec,
enabled=True,
)
rec.mount_path = norm_path
rec.sub_path = data.sub_path
await runtime_registry.refresh() await runtime_registry.refresh()
await LogService.action( await LogService.action(
"route:adapters", "route:adapters",
@@ -73,20 +68,8 @@ async def create_adapter(
async def list_adapters( async def list_adapters(
current_user: Annotated[User, Depends(get_current_active_user)] current_user: Annotated[User, Depends(get_current_active_user)]
): ):
adapters = await StorageAdapter.all().prefetch_related("mounts") adapters = await StorageAdapter.all()
out = [] out = [AdapterOut.model_validate(a) for a in adapters]
for a in adapters:
mount = a.mounts[0] if a.mounts else None
item = AdapterOut(
name=a.name,
type=a.type,
config=a.config,
enabled=a.enabled,
id=a.id,
mount_path=mount.path if mount else None,
sub_path=mount.sub_path if mount else None
)
out.append(item)
return success(out) return success(out)
@@ -109,13 +92,10 @@ async def get_adapter(
adapter_id: int, adapter_id: int,
current_user: Annotated[User, Depends(get_current_active_user)] current_user: Annotated[User, Depends(get_current_active_user)]
): ):
rec = await StorageAdapter.get_or_none(id=adapter_id).prefetch_related("mounts") rec = await StorageAdapter.get_or_none(id=adapter_id)
if not rec: if not rec:
raise HTTPException(404, detail="Not found") raise HTTPException(404, detail="Not found")
mount = rec.mounts[0] if rec.mounts else None return success(AdapterOut.model_validate(rec))
rec.mount_path = mount.path if mount else None
rec.sub_path = mount.sub_path if mount else None
return success(rec)
@router.put("/{adapter_id}") @router.put("/{adapter_id}")
@@ -124,33 +104,23 @@ async def update_adapter(
data: AdapterCreate, data: AdapterCreate,
current_user: Annotated[User, Depends(get_current_active_user)] current_user: Annotated[User, Depends(get_current_active_user)]
): ):
rec = await StorageAdapter.get_or_none(id=adapter_id)
rec = await StorageAdapter.get_or_none(id=adapter_id).prefetch_related("mounts")
if not rec: if not rec:
raise HTTPException(404, detail="Not found") raise HTTPException(404, detail="Not found")
norm_path = AdapterCreate.normalize_mount_path(data.mount_path) norm_path = AdapterCreate.normalize_mount_path(data.mount_path)
existing = await Mount.get_or_none(path=norm_path) existing = await StorageAdapter.get_or_none(path=norm_path)
mount = rec.mounts[0] if rec.mounts else None if existing and existing.id != adapter_id:
if existing and (not mount or existing.id != mount.id):
raise HTTPException(400, detail="Mount path already exists") raise HTTPException(400, detail="Mount path already exists")
rec.name = data.name rec.name = data.name
rec.type = data.type rec.type = data.type
rec.config = validate_and_normalize_config(data.type, data.config or {}) rec.config = validate_and_normalize_config(data.type, data.config or {})
rec.enabled = data.enabled rec.enabled = data.enabled
rec.path = norm_path
rec.sub_path = data.sub_path
await rec.save() await rec.save()
if mount:
mount.path = norm_path
mount.sub_path = data.sub_path
await mount.save()
else:
mount = await Mount.create(
path=norm_path,
sub_path=data.sub_path,
adapter=rec,
enabled=True,
)
rec.mount_path = mount.path
rec.sub_path = mount.sub_path
await runtime_registry.refresh() await runtime_registry.refresh()
await LogService.action( await LogService.action(
"route:adapters", "route:adapters",
-84
View File
@@ -1,84 +0,0 @@
from fastapi import APIRouter, HTTPException, Depends
from typing import Annotated
from models import StorageAdapter, Mount
from schemas import MountCreate, MountOut
from api.response import success
from services.auth import get_current_active_user, User
from services.logging import LogService
router = APIRouter(prefix="/api/mounts", tags=["mounts"])
@router.post("")
async def create_mount(
data: MountCreate,
current_user: Annotated[User, Depends(get_current_active_user)],
):
adapter = await StorageAdapter.get_or_none(id=data.adapter_id)
if not adapter:
raise HTTPException(400, detail="Adapter not found")
rec = await Mount.create(
path=MountCreate.normalize(data.path),
adapter=adapter,
sub_path=data.sub_path,
enabled=data.enabled,
)
await LogService.action(
"route:mounts",
f"Created mount {rec.path}",
details=data.model_dump(),
user_id=current_user.id if hasattr(current_user, "id") else None,
)
return success(rec)
@router.get("")
async def list_mounts(
current_user: Annotated[User, Depends(get_current_active_user)],
):
recs = await Mount.all()
return success(recs)
@router.put("/{mount_id}")
async def update_mount(
mount_id: int,
data: MountCreate,
current_user: Annotated[User, Depends(get_current_active_user)],
):
rec = await Mount.get_or_none(id=mount_id)
if not rec:
raise HTTPException(404, detail="Not found")
adapter = await StorageAdapter.get_or_none(id=data.adapter_id)
if not adapter:
raise HTTPException(400, detail="Adapter not found")
rec.path = MountCreate.normalize(data.path)
rec.adapter = adapter
rec.sub_path = data.sub_path
rec.enabled = data.enabled
await rec.save()
await LogService.action(
"route:mounts",
f"Updated mount {rec.path}",
details=data.model_dump(),
user_id=current_user.id if hasattr(current_user, "id") else None,
)
return success(rec)
@router.delete("/{mount_id}")
async def delete_mount(
mount_id: int,
current_user: Annotated[User, Depends(get_current_active_user)],
):
deleted = await Mount.filter(id=mount_id).delete()
if not deleted:
raise HTTPException(404, detail="Not found")
await LogService.action(
"route:mounts",
f"Deleted mount {mount_id}",
details={"mount_id": mount_id},
user_id=current_user.id if hasattr(current_user, "id") else None,
)
return success({"deleted": True})
+1 -1
View File
@@ -116,7 +116,7 @@ async def get_thumb(
if not is_image_filename(rel): if not is_image_filename(rel):
raise HTTPException(404, detail="Not an image") raise HTTPException(404, detail="Not an image")
# type: ignore # type: ignore
data, mime, key = await get_or_create_thumb(adapter, mount.adapter_id, root, rel, w, h, fit) data, mime, key = await get_or_create_thumb(adapter, mount.id, root, rel, w, h, fit)
headers = { headers = {
'Cache-Control': 'public, max-age=3600', 'Cache-Control': 'public, max-age=3600',
'ETag': key, 'ETag': key,
+1
View File
@@ -1,4 +1,5 @@
#!/bin/bash #!/bin/bash
set -e set -e
python migrate/run.py
nginx -g 'daemon off;' & nginx -g 'daemon off;' &
exec gunicorn -k uvicorn.workers.UvicornWorker -w 4 -b 0.0.0.0:8000 main:app exec gunicorn -k uvicorn.workers.UvicornWorker -w 4 -b 0.0.0.0:8000 main:app
+2 -2
View File
@@ -4,13 +4,13 @@ load_dotenv()
from services.middleware.exception_handler import global_exception_handler from services.middleware.exception_handler import global_exception_handler
from services.middleware.logging_middleware import LoggingMiddleware from services.middleware.logging_middleware import LoggingMiddleware
from fastapi import FastAPI, Request from fastapi import FastAPI
from api.routers import include_routers from api.routers import include_routers
from db.session import close_db, init_db from db.session import close_db, init_db
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from services.adapters.registry import runtime_registry from services.adapters.registry import runtime_registry
from services.config import VERSION
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
+2 -2
View File
@@ -1,3 +1,3 @@
from .database import StorageAdapter, Mount from .database import StorageAdapter
__all__ = ["StorageAdapter", "Mount"] __all__ = ["StorageAdapter"]
+2 -14
View File
@@ -8,25 +8,13 @@ class StorageAdapter(Model):
type = fields.CharField(max_length=30) type = fields.CharField(max_length=30)
config = fields.JSONField() config = fields.JSONField()
enabled = fields.BooleanField(default=True) enabled = fields.BooleanField(default=True)
mounts: fields.ReverseRelation["Mount"] path = fields.CharField(max_length=255, unique=True)
sub_path = fields.CharField(max_length=1024, null=True)
class Meta: class Meta:
table = "storage_adapters" table = "storage_adapters"
class Mount(Model):
id = fields.IntField(pk=True)
path = fields.CharField(max_length=255, unique=True)
sub_path = fields.CharField(max_length=1024, null=True)
adapter: fields.ForeignKeyRelation[StorageAdapter] = fields.ForeignKeyField(
"models.StorageAdapter", related_name="mounts", on_delete=fields.CASCADE
)
enabled = fields.BooleanField(default=True)
class Meta:
table = "mounts"
class UserAccount(Model): class UserAccount(Model):
id = fields.IntField(pk=True) id = fields.IntField(pk=True)
username = fields.CharField(max_length=50, unique=True) username = fields.CharField(max_length=50, unique=True)
-3
View File
@@ -1,12 +1,9 @@
from .adapters import AdapterCreate, AdapterOut from .adapters import AdapterCreate, AdapterOut
from .mounts import MountCreate, MountOut
from .fs import MkdirRequest, MoveRequest from .fs import MkdirRequest, MoveRequest
__all__ = [ __all__ = [
"AdapterCreate", "AdapterCreate",
"AdapterOut", "AdapterOut",
"MountCreate",
"MountOut",
"MkdirRequest", "MkdirRequest",
"MoveRequest", "MoveRequest",
] ]
+8 -4
View File
@@ -2,13 +2,16 @@ from typing import Dict, Optional
from pydantic import BaseModel, Field, validator from pydantic import BaseModel, Field, validator
class AdapterCreate(BaseModel): class AdapterBase(BaseModel):
name: str name: str
type: str = Field(pattern=r"^[a-zA-Z0-9_]+$") type: str = Field(pattern=r"^[a-zA-Z0-9_]+$")
config: Dict = Field(default_factory=dict) config: Dict = Field(default_factory=dict)
enabled: bool = True enabled: bool = True
mount_path: str sub_path: Optional[str] = None
sub_path: Optional[str] = None
class AdapterCreate(AdapterBase):
mount_path: str
@staticmethod @staticmethod
def normalize_mount_path(p: str) -> str: def normalize_mount_path(p: str) -> str:
@@ -25,8 +28,9 @@ class AdapterCreate(BaseModel):
return cls.normalize_mount_path(v) return cls.normalize_mount_path(v)
class AdapterOut(AdapterCreate): class AdapterOut(AdapterBase):
id: int id: int
mount_path: str = Field(alias='path')
class Config: class Config:
from_attributes = True from_attributes = True
-23
View File
@@ -1,23 +0,0 @@
from typing import Optional
from pydantic import BaseModel
class MountCreate(BaseModel):
path: str
adapter_id: int
sub_path: Optional[str] = None
enabled: bool = True
@staticmethod
def normalize(path: str) -> str:
return (path if path.startswith('/') else '/' + path).rstrip('/') or '/'
def model_post_init(self, __context):
self.path = self.normalize(self.path)
class MountOut(MountCreate):
id: int
class Config:
from_attributes = True
-10
View File
@@ -1,7 +1,6 @@
from tortoise.transactions import in_transaction from tortoise.transactions import in_transaction
from models.database import ( from models.database import (
StorageAdapter, StorageAdapter,
Mount,
UserAccount, UserAccount,
AutomationTask, AutomationTask,
ShareLink, ShareLink,
@@ -18,7 +17,6 @@ class BackupService:
""" """
async with in_transaction() as conn: async with in_transaction() as conn:
adapters = await StorageAdapter.all().values() adapters = await StorageAdapter.all().values()
mounts = await Mount.all().values()
users = await UserAccount.all().values() users = await UserAccount.all().values()
tasks = await AutomationTask.all().values() tasks = await AutomationTask.all().values()
shares = await ShareLink.all().values() shares = await ShareLink.all().values()
@@ -33,7 +31,6 @@ class BackupService:
return { return {
"version": VERSION, "version": VERSION,
"storage_adapters": list(adapters), "storage_adapters": list(adapters),
"mounts": list(mounts),
"user_accounts": list(users), "user_accounts": list(users),
"automation_tasks": list(tasks), "automation_tasks": list(tasks),
"share_links": list(shares), "share_links": list(shares),
@@ -48,7 +45,6 @@ class BackupService:
async with in_transaction() as conn: async with in_transaction() as conn:
await ShareLink.all().using_db(conn).delete() await ShareLink.all().using_db(conn).delete()
await AutomationTask.all().using_db(conn).delete() await AutomationTask.all().using_db(conn).delete()
await Mount.all().using_db(conn).delete()
await StorageAdapter.all().using_db(conn).delete() await StorageAdapter.all().using_db(conn).delete()
await UserAccount.all().using_db(conn).delete() await UserAccount.all().using_db(conn).delete()
await Configuration.all().using_db(conn).delete() await Configuration.all().using_db(conn).delete()
@@ -71,12 +67,6 @@ class BackupService:
using_db=conn using_db=conn
) )
if data.get("mounts"):
await Mount.bulk_create(
[Mount(**m) for m in data["mounts"]],
using_db=conn
)
if data.get("automation_tasks"): if data.get("automation_tasks"):
await AutomationTask.bulk_create( await AutomationTask.bulk_create(
[AutomationTask(**t) for t in data["automation_tasks"]], [AutomationTask(**t) for t in data["automation_tasks"]],
+55 -56
View File
@@ -1,4 +1,3 @@
from pathlib import Path
from typing import Dict, Tuple, Any, Union, AsyncIterator from typing import Dict, Tuple, Any, Union, AsyncIterator
from fastapi import HTTPException from fastapi import HTTPException
import mimetypes import mimetypes
@@ -8,7 +7,7 @@ import hmac
import hashlib import hashlib
import base64 import base64
from models import Mount from models import StorageAdapter
from .adapters.registry import runtime_registry from .adapters.registry import runtime_registry
from api.response import page from api.response import page
from .thumbnail import is_image_filename, is_raw_filename from .thumbnail import is_image_filename, is_raw_filename
@@ -18,16 +17,16 @@ from services.logging import LogService
from services.config import ConfigCenter from services.config import ConfigCenter
async def resolve_mount(path: str) -> Tuple[Mount, str]: async def resolve_adapter_by_path(path: str) -> Tuple[StorageAdapter, str]:
norm = path if path.startswith('/') else '/' + path norm = path if path.startswith('/') else '/' + path
mounts = await Mount.filter(enabled=True) adapters = await StorageAdapter.filter(enabled=True)
best = None best = None
for m in mounts: for a in adapters:
if norm == m.path or norm.startswith(m.path.rstrip('/') + '/'): if norm == a.path or norm.startswith(a.path.rstrip('/') + '/'):
if (best is None) or len(m.path) > len(best.path): if (best is None) or len(a.path) > len(best.path):
best = m best = a
if not best: if not best:
raise HTTPException(404, detail="No mount for path") raise HTTPException(404, detail="No storage adapter for path")
rel = norm[len(best.path):].lstrip('/') rel = norm[len(best.path):].lstrip('/')
return best, rel return best, rel
@@ -35,16 +34,17 @@ async def resolve_mount(path: str) -> Tuple[Mount, str]:
async def resolve_adapter_and_rel(path: str): async def resolve_adapter_and_rel(path: str):
"""返回 (adapter_instance, mount, effective_root, rel_path).""" """返回 (adapter_instance, adapter_model, effective_root, rel_path)."""
norm = path if path.startswith('/') else '/' + path norm = path if path.startswith('/') else '/' + path
try: try:
mount, rel = await resolve_mount(norm) adapter_model, rel = await resolve_adapter_by_path(norm)
except HTTPException as e: except HTTPException as e:
raise e raise e
await mount.fetch_related("adapter") adapter_instance = runtime_registry.get(adapter_model.id)
adapter_instance = runtime_registry.get(mount.adapter_id) if not adapter_instance:
effective_root = adapter_instance.get_effective_root(mount.sub_path) raise HTTPException(404, detail=f"Adapter instance not found for ID {adapter_model.id}")
return adapter_instance, mount, effective_root, rel effective_root = adapter_instance.get_effective_root(adapter_model.sub_path)
return adapter_instance, adapter_model, effective_root, rel
async def _ensure_method(adapter: Any, method: str): async def _ensure_method(adapter: Any, method: str):
@@ -56,27 +56,26 @@ async def _ensure_method(adapter: Any, method: str):
async def list_virtual_dir(path: str, page_num: int = 1, page_size: int = 50) -> Dict: async def list_virtual_dir(path: str, page_num: int = 1, page_size: int = 50) -> Dict:
norm = (path if path.startswith('/') else '/' + path).rstrip('/') or '/' norm = (path if path.startswith('/') else '/' + path).rstrip('/') or '/'
mounts = await Mount.filter(enabled=True).prefetch_related("adapter") adapters = await StorageAdapter.filter(enabled=True)
child_mount_entries = [] child_mount_entries = []
norm_prefix = norm.rstrip('/') norm_prefix = norm.rstrip('/')
for m in mounts: for a in adapters:
if m.path == norm: if a.path == norm:
continue continue
if m.path.startswith(norm_prefix + '/'): if a.path.startswith(norm_prefix + '/'):
tail = m.path[len(norm_prefix):].lstrip('/') tail = a.path[len(norm_prefix):].lstrip('/')
if '/' not in tail: if '/' not in tail:
child_mount_entries.append(tail) child_mount_entries.append(tail)
child_mount_entries = sorted(set(child_mount_entries)) child_mount_entries = sorted(set(child_mount_entries))
try: try:
mount, rel = await resolve_mount(norm) adapter_model, rel = await resolve_adapter_by_path(norm)
await mount.fetch_related("adapter") adapter_instance = runtime_registry.get(adapter_model.id)
adapter = runtime_registry.get(mount.adapter_id) effective_root = adapter_instance.get_effective_root(adapter_model.sub_path)
effective_root = adapter.get_effective_root(mount.sub_path)
except HTTPException: except HTTPException:
mount = None adapter_model = None
adapter = None adapter_instance = None
effective_root = '' effective_root = ''
rel = '' rel = ''
@@ -84,8 +83,8 @@ async def list_virtual_dir(path: str, page_num: int = 1, page_size: int = 50) ->
adapter_total = 0 adapter_total = 0
covered = set() covered = set()
if mount and adapter: if adapter_model and adapter_instance:
list_dir = await _ensure_method(adapter, "list_dir") list_dir = await _ensure_method(adapter_instance, "list_dir")
try: try:
adapter_entries, adapter_total = await list_dir(effective_root, rel, page_num, page_size) adapter_entries, adapter_total = await list_dir(effective_root, rel, page_num, page_size)
except NotADirectoryError: except NotADirectoryError:
@@ -119,18 +118,18 @@ async def list_virtual_dir(path: str, page_num: int = 1, page_size: int = 50) ->
async def read_file(path: str) -> Union[bytes, Any]: async def read_file(path: str) -> Union[bytes, Any]:
adapter, _mount, root, rel = await resolve_adapter_and_rel(path) adapter_instance, _, root, rel = await resolve_adapter_and_rel(path)
if rel.endswith('/') or rel == '': if rel.endswith('/') or rel == '':
raise HTTPException(400, detail="Path is a directory") raise HTTPException(400, detail="Path is a directory")
read_func = await _ensure_method(adapter, "read_file") read_func = await _ensure_method(adapter_instance, "read_file")
return await read_func(root, rel) return await read_func(root, rel)
async def write_file(path: str, data: bytes): async def write_file(path: str, data: bytes):
adapter, _mount, root, rel = await resolve_adapter_and_rel(path) adapter_instance, _, root, rel = await resolve_adapter_and_rel(path)
if rel.endswith('/'): if rel.endswith('/'):
raise HTTPException(400, detail="Invalid file path") raise HTTPException(400, detail="Invalid file path")
write_func = await _ensure_method(adapter, "write_file") write_func = await _ensure_method(adapter_instance, "write_file")
await write_func(root, rel, data) await write_func(root, rel, data)
await task_service.trigger_tasks("file_written", path) await task_service.trigger_tasks("file_written", path)
await LogService.action( await LogService.action(
@@ -139,10 +138,10 @@ async def write_file(path: str, data: bytes):
async def write_file_stream(path: str, data_iter: AsyncIterator[bytes], overwrite: bool = True): async def write_file_stream(path: str, data_iter: AsyncIterator[bytes], overwrite: bool = True):
adapter, _mount, root, rel = await resolve_adapter_and_rel(path) adapter_instance, _, root, rel = await resolve_adapter_and_rel(path)
if rel.endswith('/'): if rel.endswith('/'):
raise HTTPException(400, detail="Invalid file path") raise HTTPException(400, detail="Invalid file path")
exists_func = getattr(adapter, "exists", None) exists_func = getattr(adapter_instance, "exists", None)
if not overwrite and callable(exists_func): if not overwrite and callable(exists_func):
try: try:
if await exists_func(root, rel): if await exists_func(root, rel):
@@ -153,7 +152,7 @@ async def write_file_stream(path: str, data_iter: AsyncIterator[bytes], overwrit
pass pass
size = 0 size = 0
stream_func = getattr(adapter, "write_file_stream", None) stream_func = getattr(adapter_instance, "write_file_stream", None)
if callable(stream_func): if callable(stream_func):
size = await stream_func(root, rel, data_iter) size = await stream_func(root, rel, data_iter)
else: else:
@@ -161,7 +160,7 @@ async def write_file_stream(path: str, data_iter: AsyncIterator[bytes], overwrit
async for chunk in data_iter: async for chunk in data_iter:
if chunk: if chunk:
buf.extend(chunk) buf.extend(chunk)
write_func = await _ensure_method(adapter, "write_file") write_func = await _ensure_method(adapter_instance, "write_file")
await write_func(root, rel, bytes(buf)) await write_func(root, rel, bytes(buf))
size = len(buf) size = len(buf)
@@ -175,35 +174,35 @@ async def write_file_stream(path: str, data_iter: AsyncIterator[bytes], overwrit
async def make_dir(path: str): async def make_dir(path: str):
adapter, _mount, root, rel = await resolve_adapter_and_rel(path) adapter_instance, _, root, rel = await resolve_adapter_and_rel(path)
if not rel: if not rel:
raise HTTPException(400, detail="Cannot create root") raise HTTPException(400, detail="Cannot create root")
mkdir_func = await _ensure_method(adapter, "mkdir") mkdir_func = await _ensure_method(adapter_instance, "mkdir")
await mkdir_func(root, rel) await mkdir_func(root, rel)
await LogService.action("virtual_fs", f"Created directory {path}", details={"path": path}) await LogService.action("virtual_fs", f"Created directory {path}", details={"path": path})
async def delete_path(path: str): async def delete_path(path: str):
adapter, _mount, root, rel = await resolve_adapter_and_rel(path) adapter_instance, _, root, rel = await resolve_adapter_and_rel(path)
if not rel: if not rel:
raise HTTPException(400, detail="Cannot delete root") raise HTTPException(400, detail="Cannot delete root")
delete_func = await _ensure_method(adapter, "delete") delete_func = await _ensure_method(adapter_instance, "delete")
await delete_func(root, rel) await delete_func(root, rel)
await task_service.trigger_tasks("file_deleted", path) await task_service.trigger_tasks("file_deleted", path)
await LogService.action("virtual_fs", f"Deleted {path}", details={"path": path}) await LogService.action("virtual_fs", f"Deleted {path}", details={"path": path})
async def move_path(src: str, dst: str, overwrite: bool = False, return_debug: bool = True): async def move_path(src: str, dst: str, overwrite: bool = False, return_debug: bool = True):
adapter_s, mount_s, root_s, rel_s = await resolve_adapter_and_rel(src) adapter_s, adapter_model_s, root_s, rel_s = await resolve_adapter_and_rel(src)
adapter_d, mount_d, root_d, rel_d = await resolve_adapter_and_rel(dst) adapter_d, adapter_model_d, root_d, rel_d = await resolve_adapter_and_rel(dst)
debug_info = { debug_info = {
"src": src, "dst": dst, "src": src, "dst": dst,
"rel_s": rel_s, "rel_d": rel_d, "rel_s": rel_s, "rel_d": rel_d,
"root_s": root_s, "root_d": root_d, "root_s": root_s, "root_d": root_d,
"overwrite": overwrite "overwrite": overwrite
} }
if mount_s.id != mount_d.id: if adapter_model_s.id != adapter_model_d.id:
raise HTTPException(400, detail="Cross-mount move not supported") raise HTTPException(400, detail="Cross-adapter move not supported")
if not rel_s: if not rel_s:
raise HTTPException(400, detail="Cannot move or rename mount root") raise HTTPException(400, detail="Cannot move or rename mount root")
if not rel_d: if not rel_d:
@@ -266,16 +265,16 @@ async def move_path(src: str, dst: str, overwrite: bool = False, return_debug: b
async def rename_path(src: str, dst: str, overwrite: bool = False, return_debug: bool = True): async def rename_path(src: str, dst: str, overwrite: bool = False, return_debug: bool = True):
adapter_s, mount_s, root_s, rel_s = await resolve_adapter_and_rel(src) adapter_s, adapter_model_s, root_s, rel_s = await resolve_adapter_and_rel(src)
adapter_d, mount_d, root_d, rel_d = await resolve_adapter_and_rel(dst) adapter_d, adapter_model_d, root_d, rel_d = await resolve_adapter_and_rel(dst)
debug_info = { debug_info = {
"src": src, "dst": dst, "src": src, "dst": dst,
"rel_s": rel_s, "rel_d": rel_d, "rel_s": rel_s, "rel_d": rel_d,
"root_s": root_s, "root_d": root_d, "root_s": root_s, "root_d": root_d,
"overwrite": overwrite "overwrite": overwrite
} }
if mount_s.id != mount_d.id: if adapter_model_s.id != adapter_model_d.id:
raise HTTPException(400, detail="Cross-mount rename not supported") raise HTTPException(400, detail="Cross-adapter rename not supported")
if not rel_s: if not rel_s:
raise HTTPException(400, detail="Cannot rename mount root") raise HTTPException(400, detail="Cannot rename mount root")
if not rel_d: if not rel_d:
@@ -338,7 +337,7 @@ async def rename_path(src: str, dst: str, overwrite: bool = False, return_debug:
async def stream_file(path: str, range_header: str | None): async def stream_file(path: str, range_header: str | None):
adapter, mount, root, rel = await resolve_adapter_and_rel(path) adapter_instance, _, root, rel = await resolve_adapter_and_rel(path)
if not rel or rel.endswith('/'): if not rel or rel.endswith('/'):
raise HTTPException(400, detail="Path is a directory") raise HTTPException(400, detail="Path is a directory")
if is_raw_filename(rel): if is_raw_filename(rel):
@@ -371,7 +370,7 @@ async def stream_file(path: str, range_header: str | None):
except Exception as e: except Exception as e:
raise HTTPException(500, detail=f"RAW file processing failed: {e}") raise HTTPException(500, detail=f"RAW file processing failed: {e}")
stream_impl = getattr(adapter, "stream_file", None) stream_impl = getattr(adapter_instance, "stream_file", None)
if callable(stream_impl): if callable(stream_impl):
return await stream_impl(root, rel, range_header) return await stream_impl(root, rel, range_header)
data = await read_file(path) data = await read_file(path)
@@ -380,24 +379,24 @@ async def stream_file(path: str, range_header: str | None):
async def stat_file(path: str): async def stat_file(path: str):
adapter, _mount, root, rel = await resolve_adapter_and_rel(path) adapter_instance, _, root, rel = await resolve_adapter_and_rel(path)
stat_func = getattr(adapter, "stat_file", None) stat_func = getattr(adapter_instance, "stat_file", None)
if not callable(stat_func): if not callable(stat_func):
raise HTTPException(501, detail="Adapter does not implement stat_file") raise HTTPException(501, detail="Adapter does not implement stat_file")
return await stat_func(root, rel) return await stat_func(root, rel)
async def copy_path(src: str, dst: str, overwrite: bool = False, return_debug: bool = True): async def copy_path(src: str, dst: str, overwrite: bool = False, return_debug: bool = True):
adapter_s, mount_s, root_s, rel_s = await resolve_adapter_and_rel(src) adapter_s, adapter_model_s, root_s, rel_s = await resolve_adapter_and_rel(src)
adapter_d, mount_d, root_d, rel_d = await resolve_adapter_and_rel(dst) adapter_d, adapter_model_d, root_d, rel_d = await resolve_adapter_and_rel(dst)
debug_info = { debug_info = {
"src": src, "dst": dst, "src": src, "dst": dst,
"rel_s": rel_s, "rel_d": rel_d, "rel_s": rel_s, "rel_d": rel_d,
"root_s": root_s, "root_d": root_d, "root_s": root_s, "root_d": root_d,
"overwrite": overwrite "overwrite": overwrite
} }
if mount_s.id != mount_d.id: if adapter_model_s.id != adapter_model_d.id:
raise HTTPException(400, detail="Cross-mount copy not supported") raise HTTPException(400, detail="Cross-adapter copy not supported")
if not rel_s: if not rel_s:
raise HTTPException(400, detail="Cannot copy mount root") raise HTTPException(400, detail="Cannot copy mount root")
if not rel_d: if not rel_d:
+65 -7
View File
@@ -1,4 +1,4 @@
import { Layout, Menu, theme, Button, Modal, Tag, Tooltip } from 'antd'; import { Layout, Menu, theme, Button, Modal, Tag, Tooltip, Descriptions, Alert, Divider, Spin } from 'antd';
import { navGroups } from './nav.ts'; import { navGroups } from './nav.ts';
import type { NavItem, NavGroup } from './nav.ts'; import type { NavItem, NavGroup } from './nav.ts';
import { memo, useEffect, useState } from 'react'; import { memo, useEffect, useState } from 'react';
@@ -225,13 +225,71 @@ const SideNav = memo(function SideNav({ collapsed, activeKey, onChange, onToggle
onCancel={() => setIsVersionModalOpen(false)} onCancel={() => setIsVersionModalOpen(false)}
title="版本信息" title="版本信息"
footer={null} footer={null}
width={600}
> >
<div> <div style={{ paddingTop: 12 }}>
<p>: {status?.version}</p> {latestVersion ? (
{latestVersion && ( <>
<div> <Descriptions bordered column={1} size="small">
<p>: {latestVersion.version}</p> <Descriptions.Item label="当前版本">
<ReactMarkdown>{latestVersion.body}</ReactMarkdown> <Tag>{status?.version}</Tag>
</Descriptions.Item>
<Descriptions.Item label="最新版本">
<Tag color={hasUpdate ? 'orange' : 'green'}>{latestVersion.version}</Tag>
</Descriptions.Item>
</Descriptions>
{hasUpdate && (
<Alert
message={`发现新版本: ${latestVersion.version}`}
description="建议尽快更新到最新版本,以获得新功能和安全修复。"
type="info"
showIcon
style={{ marginTop: 24, marginBottom: 24 }}
action={
<Button
size="small"
type="primary"
href="https://github.com/DrizzleTime/Foxel/releases"
target="_blank"
icon={<GithubOutlined />}
>
</Button>
}
/>
)}
<Divider orientation="left" plain></Divider>
<div style={{
maxHeight: '40vh',
overflowY: 'auto',
padding: '8px 16px',
background: token.colorFillAlter,
borderRadius: token.borderRadiusLG,
border: `1px solid ${token.colorBorderSecondary}`
}}>
<ReactMarkdown
components={{
h3: ({ ...props }) => <h3 style={{
fontSize: 16,
borderBottom: `1px solid ${token.colorBorderSecondary}`,
paddingBottom: 8,
marginTop: 24,
marginBottom: 16
}} {...props} />,
ul: ({ ...props }) => <ul style={{ paddingLeft: 20 }} {...props} />,
li: ({ ...props }) => <li style={{ marginBottom: 8 }} {...props} />,
p: ({ ...props }) => <p style={{ marginBottom: 8 }} {...props} />,
a: ({ ...props }) => <a {...props} target="_blank" rel="noopener noreferrer" />
}}
>{latestVersion.body}</ReactMarkdown>
</div>
</>
) : (
<div style={{ textAlign: 'center', padding: '40px 0', color: token.colorTextSecondary }}>
<Spin size="large" />
<p style={{ marginTop: 16 }}>...</p>
</div> </div>
)} )}
</div> </div>