mirror of
https://github.com/DrizzleTime/Foxel.git
synced 2026-05-18 08:37:37 +08:00
feat: Refactor storage adapter and mount handling; migrate mounts to storage adapters; enhance SideNav; implement database migration scripts
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -5,5 +5,5 @@ __pycache__/
|
||||
.venv/
|
||||
.vscode/
|
||||
data/
|
||||
|
||||
migrate/
|
||||
.env
|
||||
@@ -13,11 +13,13 @@ FROM python:3.13-slim
|
||||
|
||||
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 .
|
||||
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 . .
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
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):
|
||||
app.include_router(adapters.router)
|
||||
app.include_router(virtual_fs.router)
|
||||
app.include_router(search.router)
|
||||
app.include_router(mounts.router)
|
||||
app.include_router(auth.router)
|
||||
app.include_router(config.router)
|
||||
app.include_router(processors.router)
|
||||
|
||||
@@ -2,7 +2,7 @@ from fastapi import APIRouter, HTTPException, Depends
|
||||
from tortoise.transactions import in_transaction
|
||||
from typing import Annotated
|
||||
|
||||
from models import StorageAdapter, Mount
|
||||
from models import StorageAdapter
|
||||
from schemas import AdapterCreate, AdapterOut
|
||||
from services.auth import get_current_active_user, User
|
||||
from services.adapters.registry import runtime_registry, get_config_schemas
|
||||
@@ -39,26 +39,21 @@ async def create_adapter(
|
||||
data: AdapterCreate,
|
||||
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 = {
|
||||
"name": data.name,
|
||||
"type": data.type,
|
||||
"config": validate_and_normalize_config(data.type, data.config or {}),
|
||||
"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)
|
||||
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
|
||||
|
||||
rec = await StorageAdapter.create(**adapter_fields)
|
||||
await runtime_registry.refresh()
|
||||
await LogService.action(
|
||||
"route:adapters",
|
||||
@@ -73,20 +68,8 @@ async def create_adapter(
|
||||
async def list_adapters(
|
||||
current_user: Annotated[User, Depends(get_current_active_user)]
|
||||
):
|
||||
adapters = await StorageAdapter.all().prefetch_related("mounts")
|
||||
out = []
|
||||
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)
|
||||
adapters = await StorageAdapter.all()
|
||||
out = [AdapterOut.model_validate(a) for a in adapters]
|
||||
return success(out)
|
||||
|
||||
|
||||
@@ -109,13 +92,10 @@ async def get_adapter(
|
||||
adapter_id: int,
|
||||
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:
|
||||
raise HTTPException(404, detail="Not found")
|
||||
mount = rec.mounts[0] if rec.mounts else None
|
||||
rec.mount_path = mount.path if mount else None
|
||||
rec.sub_path = mount.sub_path if mount else None
|
||||
return success(rec)
|
||||
return success(AdapterOut.model_validate(rec))
|
||||
|
||||
|
||||
@router.put("/{adapter_id}")
|
||||
@@ -124,33 +104,23 @@ async def update_adapter(
|
||||
data: AdapterCreate,
|
||||
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:
|
||||
raise HTTPException(404, detail="Not found")
|
||||
|
||||
norm_path = AdapterCreate.normalize_mount_path(data.mount_path)
|
||||
existing = await Mount.get_or_none(path=norm_path)
|
||||
mount = rec.mounts[0] if rec.mounts else None
|
||||
if existing and (not mount or existing.id != mount.id):
|
||||
existing = await StorageAdapter.get_or_none(path=norm_path)
|
||||
if existing and existing.id != adapter_id:
|
||||
raise HTTPException(400, detail="Mount path already exists")
|
||||
|
||||
rec.name = data.name
|
||||
rec.type = data.type
|
||||
rec.config = validate_and_normalize_config(data.type, data.config or {})
|
||||
rec.enabled = data.enabled
|
||||
rec.path = norm_path
|
||||
rec.sub_path = data.sub_path
|
||||
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 LogService.action(
|
||||
"route:adapters",
|
||||
|
||||
@@ -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})
|
||||
@@ -116,7 +116,7 @@ async def get_thumb(
|
||||
if not is_image_filename(rel):
|
||||
raise HTTPException(404, detail="Not an image")
|
||||
# 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 = {
|
||||
'Cache-Control': 'public, max-age=3600',
|
||||
'ETag': key,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
python migrate/run.py
|
||||
nginx -g 'daemon off;' &
|
||||
exec gunicorn -k uvicorn.workers.UvicornWorker -w 4 -b 0.0.0.0:8000 main:app
|
||||
4
main.py
4
main.py
@@ -4,13 +4,13 @@ load_dotenv()
|
||||
|
||||
from services.middleware.exception_handler import global_exception_handler
|
||||
from services.middleware.logging_middleware import LoggingMiddleware
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi import FastAPI
|
||||
from api.routers import include_routers
|
||||
from db.session import close_db, init_db
|
||||
from contextlib import asynccontextmanager
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from services.adapters.registry import runtime_registry
|
||||
|
||||
from services.config import VERSION
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
from .database import StorageAdapter, Mount
|
||||
from .database import StorageAdapter
|
||||
|
||||
__all__ = ["StorageAdapter", "Mount"]
|
||||
__all__ = ["StorageAdapter"]
|
||||
|
||||
@@ -8,25 +8,13 @@ class StorageAdapter(Model):
|
||||
type = fields.CharField(max_length=30)
|
||||
config = fields.JSONField()
|
||||
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:
|
||||
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):
|
||||
id = fields.IntField(pk=True)
|
||||
username = fields.CharField(max_length=50, unique=True)
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
from .adapters import AdapterCreate, AdapterOut
|
||||
from .mounts import MountCreate, MountOut
|
||||
from .fs import MkdirRequest, MoveRequest
|
||||
|
||||
__all__ = [
|
||||
"AdapterCreate",
|
||||
"AdapterOut",
|
||||
"MountCreate",
|
||||
"MountOut",
|
||||
"MkdirRequest",
|
||||
"MoveRequest",
|
||||
]
|
||||
|
||||
@@ -2,13 +2,16 @@ from typing import Dict, Optional
|
||||
from pydantic import BaseModel, Field, validator
|
||||
|
||||
|
||||
class AdapterCreate(BaseModel):
|
||||
class AdapterBase(BaseModel):
|
||||
name: str
|
||||
type: str = Field(pattern=r"^[a-zA-Z0-9_]+$")
|
||||
config: Dict = Field(default_factory=dict)
|
||||
enabled: bool = True
|
||||
mount_path: str
|
||||
sub_path: Optional[str] = None
|
||||
sub_path: Optional[str] = None
|
||||
|
||||
|
||||
class AdapterCreate(AdapterBase):
|
||||
mount_path: str
|
||||
|
||||
@staticmethod
|
||||
def normalize_mount_path(p: str) -> str:
|
||||
@@ -25,8 +28,9 @@ class AdapterCreate(BaseModel):
|
||||
return cls.normalize_mount_path(v)
|
||||
|
||||
|
||||
class AdapterOut(AdapterCreate):
|
||||
class AdapterOut(AdapterBase):
|
||||
id: int
|
||||
mount_path: str = Field(alias='path')
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
@@ -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
|
||||
@@ -1,7 +1,6 @@
|
||||
from tortoise.transactions import in_transaction
|
||||
from models.database import (
|
||||
StorageAdapter,
|
||||
Mount,
|
||||
UserAccount,
|
||||
AutomationTask,
|
||||
ShareLink,
|
||||
@@ -18,7 +17,6 @@ class BackupService:
|
||||
"""
|
||||
async with in_transaction() as conn:
|
||||
adapters = await StorageAdapter.all().values()
|
||||
mounts = await Mount.all().values()
|
||||
users = await UserAccount.all().values()
|
||||
tasks = await AutomationTask.all().values()
|
||||
shares = await ShareLink.all().values()
|
||||
@@ -33,7 +31,6 @@ class BackupService:
|
||||
return {
|
||||
"version": VERSION,
|
||||
"storage_adapters": list(adapters),
|
||||
"mounts": list(mounts),
|
||||
"user_accounts": list(users),
|
||||
"automation_tasks": list(tasks),
|
||||
"share_links": list(shares),
|
||||
@@ -48,7 +45,6 @@ class BackupService:
|
||||
async with in_transaction() as conn:
|
||||
await ShareLink.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 UserAccount.all().using_db(conn).delete()
|
||||
await Configuration.all().using_db(conn).delete()
|
||||
@@ -71,12 +67,6 @@ class BackupService:
|
||||
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"):
|
||||
await AutomationTask.bulk_create(
|
||||
[AutomationTask(**t) for t in data["automation_tasks"]],
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
from pathlib import Path
|
||||
from typing import Dict, Tuple, Any, Union, AsyncIterator
|
||||
from fastapi import HTTPException
|
||||
import mimetypes
|
||||
@@ -8,7 +7,7 @@ import hmac
|
||||
import hashlib
|
||||
import base64
|
||||
|
||||
from models import Mount
|
||||
from models import StorageAdapter
|
||||
from .adapters.registry import runtime_registry
|
||||
from api.response import page
|
||||
from .thumbnail import is_image_filename, is_raw_filename
|
||||
@@ -18,16 +17,16 @@ from services.logging import LogService
|
||||
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
|
||||
mounts = await Mount.filter(enabled=True)
|
||||
adapters = await StorageAdapter.filter(enabled=True)
|
||||
best = None
|
||||
for m in mounts:
|
||||
if norm == m.path or norm.startswith(m.path.rstrip('/') + '/'):
|
||||
if (best is None) or len(m.path) > len(best.path):
|
||||
best = m
|
||||
for a in adapters:
|
||||
if norm == a.path or norm.startswith(a.path.rstrip('/') + '/'):
|
||||
if (best is None) or len(a.path) > len(best.path):
|
||||
best = a
|
||||
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('/')
|
||||
return best, rel
|
||||
|
||||
@@ -35,16 +34,17 @@ async def resolve_mount(path: str) -> Tuple[Mount, 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
|
||||
try:
|
||||
mount, rel = await resolve_mount(norm)
|
||||
adapter_model, rel = await resolve_adapter_by_path(norm)
|
||||
except HTTPException as e:
|
||||
raise e
|
||||
await mount.fetch_related("adapter")
|
||||
adapter_instance = runtime_registry.get(mount.adapter_id)
|
||||
effective_root = adapter_instance.get_effective_root(mount.sub_path)
|
||||
return adapter_instance, mount, effective_root, rel
|
||||
adapter_instance = runtime_registry.get(adapter_model.id)
|
||||
if not adapter_instance:
|
||||
raise HTTPException(404, detail=f"Adapter instance not found for ID {adapter_model.id}")
|
||||
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):
|
||||
@@ -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:
|
||||
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 = []
|
||||
norm_prefix = norm.rstrip('/')
|
||||
for m in mounts:
|
||||
if m.path == norm:
|
||||
for a in adapters:
|
||||
if a.path == norm:
|
||||
continue
|
||||
if m.path.startswith(norm_prefix + '/'):
|
||||
tail = m.path[len(norm_prefix):].lstrip('/')
|
||||
if a.path.startswith(norm_prefix + '/'):
|
||||
tail = a.path[len(norm_prefix):].lstrip('/')
|
||||
if '/' not in tail:
|
||||
child_mount_entries.append(tail)
|
||||
child_mount_entries = sorted(set(child_mount_entries))
|
||||
|
||||
try:
|
||||
mount, rel = await resolve_mount(norm)
|
||||
await mount.fetch_related("adapter")
|
||||
adapter = runtime_registry.get(mount.adapter_id)
|
||||
effective_root = adapter.get_effective_root(mount.sub_path)
|
||||
adapter_model, rel = await resolve_adapter_by_path(norm)
|
||||
adapter_instance = runtime_registry.get(adapter_model.id)
|
||||
effective_root = adapter_instance.get_effective_root(adapter_model.sub_path)
|
||||
except HTTPException:
|
||||
mount = None
|
||||
adapter = None
|
||||
adapter_model = None
|
||||
adapter_instance = None
|
||||
effective_root = ''
|
||||
rel = ''
|
||||
|
||||
@@ -84,8 +83,8 @@ async def list_virtual_dir(path: str, page_num: int = 1, page_size: int = 50) ->
|
||||
adapter_total = 0
|
||||
covered = set()
|
||||
|
||||
if mount and adapter:
|
||||
list_dir = await _ensure_method(adapter, "list_dir")
|
||||
if adapter_model and adapter_instance:
|
||||
list_dir = await _ensure_method(adapter_instance, "list_dir")
|
||||
try:
|
||||
adapter_entries, adapter_total = await list_dir(effective_root, rel, page_num, page_size)
|
||||
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]:
|
||||
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 == '':
|
||||
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)
|
||||
|
||||
|
||||
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('/'):
|
||||
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 task_service.trigger_tasks("file_written", path)
|
||||
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):
|
||||
adapter, _mount, root, rel = await resolve_adapter_and_rel(path)
|
||||
adapter_instance, _, root, rel = await resolve_adapter_and_rel(path)
|
||||
if rel.endswith('/'):
|
||||
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):
|
||||
try:
|
||||
if await exists_func(root, rel):
|
||||
@@ -153,7 +152,7 @@ async def write_file_stream(path: str, data_iter: AsyncIterator[bytes], overwrit
|
||||
pass
|
||||
|
||||
size = 0
|
||||
stream_func = getattr(adapter, "write_file_stream", None)
|
||||
stream_func = getattr(adapter_instance, "write_file_stream", None)
|
||||
if callable(stream_func):
|
||||
size = await stream_func(root, rel, data_iter)
|
||||
else:
|
||||
@@ -161,7 +160,7 @@ async def write_file_stream(path: str, data_iter: AsyncIterator[bytes], overwrit
|
||||
async for chunk in data_iter:
|
||||
if 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))
|
||||
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):
|
||||
adapter, _mount, root, rel = await resolve_adapter_and_rel(path)
|
||||
adapter_instance, _, root, rel = await resolve_adapter_and_rel(path)
|
||||
if not rel:
|
||||
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 LogService.action("virtual_fs", f"Created directory {path}", details={"path": path})
|
||||
|
||||
|
||||
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:
|
||||
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 task_service.trigger_tasks("file_deleted", 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):
|
||||
adapter_s, mount_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_s, adapter_model_s, root_s, rel_s = await resolve_adapter_and_rel(src)
|
||||
adapter_d, adapter_model_d, root_d, rel_d = await resolve_adapter_and_rel(dst)
|
||||
debug_info = {
|
||||
"src": src, "dst": dst,
|
||||
"rel_s": rel_s, "rel_d": rel_d,
|
||||
"root_s": root_s, "root_d": root_d,
|
||||
"overwrite": overwrite
|
||||
}
|
||||
if mount_s.id != mount_d.id:
|
||||
raise HTTPException(400, detail="Cross-mount move not supported")
|
||||
if adapter_model_s.id != adapter_model_d.id:
|
||||
raise HTTPException(400, detail="Cross-adapter move not supported")
|
||||
if not rel_s:
|
||||
raise HTTPException(400, detail="Cannot move or rename mount root")
|
||||
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):
|
||||
adapter_s, mount_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_s, adapter_model_s, root_s, rel_s = await resolve_adapter_and_rel(src)
|
||||
adapter_d, adapter_model_d, root_d, rel_d = await resolve_adapter_and_rel(dst)
|
||||
debug_info = {
|
||||
"src": src, "dst": dst,
|
||||
"rel_s": rel_s, "rel_d": rel_d,
|
||||
"root_s": root_s, "root_d": root_d,
|
||||
"overwrite": overwrite
|
||||
}
|
||||
if mount_s.id != mount_d.id:
|
||||
raise HTTPException(400, detail="Cross-mount rename not supported")
|
||||
if adapter_model_s.id != adapter_model_d.id:
|
||||
raise HTTPException(400, detail="Cross-adapter rename not supported")
|
||||
if not rel_s:
|
||||
raise HTTPException(400, detail="Cannot rename mount root")
|
||||
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):
|
||||
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('/'):
|
||||
raise HTTPException(400, detail="Path is a directory")
|
||||
if is_raw_filename(rel):
|
||||
@@ -371,7 +370,7 @@ async def stream_file(path: str, range_header: str | None):
|
||||
except Exception as 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):
|
||||
return await stream_impl(root, rel, range_header)
|
||||
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):
|
||||
adapter, _mount, root, rel = await resolve_adapter_and_rel(path)
|
||||
stat_func = getattr(adapter, "stat_file", None)
|
||||
adapter_instance, _, root, rel = await resolve_adapter_and_rel(path)
|
||||
stat_func = getattr(adapter_instance, "stat_file", None)
|
||||
if not callable(stat_func):
|
||||
raise HTTPException(501, detail="Adapter does not implement stat_file")
|
||||
return await stat_func(root, rel)
|
||||
|
||||
|
||||
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_d, mount_d, root_d, rel_d = await resolve_adapter_and_rel(dst)
|
||||
adapter_s, adapter_model_s, root_s, rel_s = await resolve_adapter_and_rel(src)
|
||||
adapter_d, adapter_model_d, root_d, rel_d = await resolve_adapter_and_rel(dst)
|
||||
debug_info = {
|
||||
"src": src, "dst": dst,
|
||||
"rel_s": rel_s, "rel_d": rel_d,
|
||||
"root_s": root_s, "root_d": root_d,
|
||||
"overwrite": overwrite
|
||||
}
|
||||
if mount_s.id != mount_d.id:
|
||||
raise HTTPException(400, detail="Cross-mount copy not supported")
|
||||
if adapter_model_s.id != adapter_model_d.id:
|
||||
raise HTTPException(400, detail="Cross-adapter copy not supported")
|
||||
if not rel_s:
|
||||
raise HTTPException(400, detail="Cannot copy mount root")
|
||||
if not rel_d:
|
||||
|
||||
@@ -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 type { NavItem, NavGroup } from './nav.ts';
|
||||
import { memo, useEffect, useState } from 'react';
|
||||
@@ -225,13 +225,71 @@ const SideNav = memo(function SideNav({ collapsed, activeKey, onChange, onToggle
|
||||
onCancel={() => setIsVersionModalOpen(false)}
|
||||
title="版本信息"
|
||||
footer={null}
|
||||
width={600}
|
||||
>
|
||||
<div>
|
||||
<p>当前版本: {status?.version}</p>
|
||||
{latestVersion && (
|
||||
<div>
|
||||
<p>最新版本: {latestVersion.version}</p>
|
||||
<ReactMarkdown>{latestVersion.body}</ReactMarkdown>
|
||||
<div style={{ paddingTop: 12 }}>
|
||||
{latestVersion ? (
|
||||
<>
|
||||
<Descriptions bordered column={1} size="small">
|
||||
<Descriptions.Item label="当前版本">
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user