mirror of
https://github.com/DrizzleTime/Foxel.git
synced 2026-05-08 19:02:53 +08:00
Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf8d10f71c | ||
|
|
5c4d3a625b | ||
|
|
f0a51c3369 | ||
|
|
3278896d4b | ||
|
|
219f3e81b8 | ||
|
|
8ef0a34642 | ||
|
|
8aaa2900ef | ||
|
|
e3e68f5397 | ||
|
|
78dfbac458 | ||
|
|
583db651a7 | ||
|
|
3a15362422 | ||
|
|
e55a09d84f | ||
|
|
8957174e6f | ||
|
|
abb6b0ce22 | ||
|
|
74df438053 | ||
|
|
f271a8bee5 | ||
|
|
17236e601f | ||
|
|
71e5f84eb7 | ||
|
|
4e724b9c4a | ||
|
|
ba62bd0d4a | ||
|
|
138296e5a6 | ||
|
|
51326dea08 | ||
|
|
ac6d8ff7ad | ||
|
|
029aa2574d | ||
|
|
eeb0e6aa70 | ||
|
|
d1ceb7ddba |
1
.github/FUNDING.yml
vendored
Normal file
1
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1 @@
|
||||
custom: https://foxel.cc/sponsor.html
|
||||
75
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
75
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
@@ -0,0 +1,75 @@
|
||||
name: Bug Report / 缺陷报告
|
||||
description: Report reproducible defects with clear context / 请提供可复现的缺陷信息
|
||||
title: "[Bug] "
|
||||
labels:
|
||||
- bug
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for helping us improve Foxel! / 感谢你帮助改进 Foxel!
|
||||
Please confirm the checklist below before filing. / 在提交前请确认以下事项。
|
||||
- type: checkboxes
|
||||
id: validations
|
||||
attributes:
|
||||
label: Pre-flight Check / 提交前检查
|
||||
options:
|
||||
- label: I searched existing issues and docs / 我已搜索现有 Issue 与文档
|
||||
required: true
|
||||
- label: This is not a question or feature request / 这不是问题咨询或功能需求
|
||||
required: true
|
||||
- type: textarea
|
||||
id: summary
|
||||
attributes:
|
||||
label: Bug Summary / 缺陷摘要
|
||||
description: Briefly describe what is wrong / 简要说明出现了什么问题
|
||||
placeholder: e.g. Upload fails with 500 error / 例如:上传时报 500 错误
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: steps
|
||||
attributes:
|
||||
label: Steps to Reproduce / 复现步骤
|
||||
description: List numbered steps to trigger the bug / 列出触发问题的步骤
|
||||
placeholder: |
|
||||
1. ...
|
||||
2. ...
|
||||
3. ...
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: Expected Behavior / 预期行为
|
||||
description: What should happen instead? / 期望看到什么结果?
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: actual
|
||||
attributes:
|
||||
label: Actual Behavior / 实际行为
|
||||
description: What actually happens? Include messages or screenshots / 实际发生了什么?可附报错或截图
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: Version / 版本信息
|
||||
description: Git commit, tag, or build number / 提供 Git 提交、标签或构建号
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
id: environment
|
||||
attributes:
|
||||
label: Environment / 运行环境
|
||||
description: OS, browser, API server config, etc. / 操作系统、浏览器、服务端配置等
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Logs & Attachments / 日志与附件
|
||||
description: Paste relevant logs, stack traces, screenshots / 粘贴相关日志、堆栈或截图
|
||||
render: shell
|
||||
validations:
|
||||
required: false
|
||||
56
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
56
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
@@ -0,0 +1,56 @@
|
||||
name: Feature Request / 功能需求
|
||||
description: Suggest enhancements or new capabilities / 提出改进或新增能力
|
||||
title: "[Feature] "
|
||||
labels:
|
||||
- enhancement
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Tell us about your idea! / 欢迎分享你的想法!
|
||||
Please complete the sections below so we can evaluate it quickly. / 请完整填写以下信息,便于快速评估。
|
||||
- type: checkboxes
|
||||
id: prechecks
|
||||
attributes:
|
||||
label: Pre-flight Check / 提交前检查
|
||||
options:
|
||||
- label: I searched existing issues and roadmap / 我已搜索现有 Issue 与路线图
|
||||
required: true
|
||||
- label: This is not a bug report or question / 这不是缺陷或问题咨询
|
||||
required: true
|
||||
- type: textarea
|
||||
id: summary
|
||||
attributes:
|
||||
label: Feature Summary / 功能概述
|
||||
description: What do you want to build? / 希望新增什么能力?
|
||||
placeholder: e.g. Support sharing download links / 例如:支持分享下载链接
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: motivation
|
||||
attributes:
|
||||
label: Motivation / 背景与价值
|
||||
description: Why is this feature important? Who benefits? / 为什么重要?受益者是谁?
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: scope
|
||||
attributes:
|
||||
label: Proposed Solution / 建议方案
|
||||
description: Outline how the feature might work, including API or UI hints / 描述可能的实现方式,包含 API 或 UI 提示
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: Alternatives / 可选方案
|
||||
description: List any alternatives considered / 如有考虑过其他方案请列出
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
id: extra
|
||||
attributes:
|
||||
label: Additional Context / 补充信息
|
||||
description: Diagrams, sketches, links, constraints, etc. / 可附上草图、链接或约束
|
||||
validations:
|
||||
required: false
|
||||
42
.github/ISSUE_TEMPLATE/question.yml
vendored
Normal file
42
.github/ISSUE_TEMPLATE/question.yml
vendored
Normal file
@@ -0,0 +1,42 @@
|
||||
name: Question / 问题咨询
|
||||
description: Ask about usage, configuration, or clarification / 用于使用、配置或澄清问题
|
||||
title: "[Question] "
|
||||
labels:
|
||||
- question
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Need help? You're in the right place. / 需要帮助?请按以下提示填写。
|
||||
Check the docs before filing. / 提交前请先查阅文档。
|
||||
- type: checkboxes
|
||||
id: prechecks
|
||||
attributes:
|
||||
label: Pre-flight Check / 提交前检查
|
||||
options:
|
||||
- label: I searched existing issues and discussions / 我已搜索现有 Issue 和讨论
|
||||
required: true
|
||||
- label: I read the relevant documentation / 我已阅读相关文档
|
||||
required: true
|
||||
- type: textarea
|
||||
id: question
|
||||
attributes:
|
||||
label: Question Details / 问题详情
|
||||
description: What do you need help with? Be specific. / 具体说明需要帮助的内容
|
||||
placeholder: Describe the scenario, expectation, and blockers / 说明场景、期望结果与阻碍
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: tried
|
||||
attributes:
|
||||
label: What You Tried / 已尝试方案
|
||||
description: List commands, configs, or steps attempted / 列出尝试过的命令、配置或步骤
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
id: context
|
||||
attributes:
|
||||
label: Additional Context / 补充信息
|
||||
description: Environment details, logs, screenshots / 可补充运行环境、日志或截图
|
||||
validations:
|
||||
required: false
|
||||
51
.github/workflows/docker-clean.yml
vendored
Normal file
51
.github/workflows/docker-clean.yml
vendored
Normal file
@@ -0,0 +1,51 @@
|
||||
name: Clean dangling Docker images
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
docker-clean:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Delete untagged GHCR versions
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
OWNER="${GITHUB_REPOSITORY_OWNER}"
|
||||
PACKAGE="$(echo "${GITHUB_REPOSITORY##*/}" | tr '[:upper:]' '[:lower:]')"
|
||||
|
||||
OWNER_TYPE="$(gh api "/users/${OWNER}" -q '.type')"
|
||||
if [[ "${OWNER_TYPE}" == "Organization" ]]; then
|
||||
SCOPE="orgs/${OWNER}"
|
||||
else
|
||||
SCOPE="users/${OWNER}"
|
||||
fi
|
||||
|
||||
BASE_PATH="/${SCOPE}/packages/container/${PACKAGE}"
|
||||
|
||||
if ! gh api "${BASE_PATH}" >/dev/null 2>&1; then
|
||||
echo "Package ghcr.io/${OWNER}/${PACKAGE} not found or accessible. Nothing to clean."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
mapfile -t VERSION_IDS < <(gh api --paginate "${BASE_PATH}/versions?per_page=100" \
|
||||
-q '.[] | select(.metadata.container.tags | length == 0) | .id')
|
||||
|
||||
if [[ ${#VERSION_IDS[@]} -eq 0 ]]; then
|
||||
echo "No untagged versions to delete."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Deleting ${#VERSION_IDS[@]} untagged versions from ghcr.io/${OWNER}/${PACKAGE}..."
|
||||
for id in "${VERSION_IDS[@]}"; do
|
||||
gh api -X DELETE "${BASE_PATH}/versions/${id}" >/dev/null
|
||||
echo "Deleted version ${id}"
|
||||
done
|
||||
|
||||
echo "Cleanup complete."
|
||||
27
.gitignore
vendored
27
.gitignore
vendored
@@ -7,4 +7,29 @@ __pycache__/
|
||||
data/
|
||||
migrate/
|
||||
.env
|
||||
AGENTS.md
|
||||
AGENTS.md
|
||||
|
||||
# Logs
|
||||
/web/logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
/web/node_modules
|
||||
/web/dist
|
||||
/web/dist-ssr
|
||||
/web/*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@@ -1,7 +1,7 @@
|
||||
from fastapi import FastAPI
|
||||
|
||||
from .routes import adapters, virtual_fs, auth, config, processors, tasks, logs, share, backup, search, vector_db, offline_downloads, ai_providers
|
||||
from .routes import webdav
|
||||
from .routes import adapters, virtual_fs, auth, config, processors, tasks, logs, share, backup, search, vector_db, offline_downloads, ai_providers, email
|
||||
from .routes import webdav, s3
|
||||
from .routes import plugins
|
||||
|
||||
|
||||
@@ -21,4 +21,6 @@ def include_routers(app: FastAPI):
|
||||
app.include_router(ai_providers.router)
|
||||
app.include_router(plugins.router)
|
||||
app.include_router(webdav.router)
|
||||
app.include_router(s3.router)
|
||||
app.include_router(offline_downloads.router)
|
||||
app.include_router(email.router)
|
||||
|
||||
@@ -5,7 +5,7 @@ from typing import Annotated
|
||||
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
|
||||
from services.adapters.registry import runtime_registry, get_config_schemas, normalize_adapter_type
|
||||
from api.response import success
|
||||
from services.logging import LogService
|
||||
|
||||
@@ -14,6 +14,9 @@ router = APIRouter(prefix="/api/adapters", tags=["adapters"])
|
||||
|
||||
def validate_and_normalize_config(adapter_type: str, cfg):
|
||||
schemas = get_config_schemas()
|
||||
adapter_type = normalize_adapter_type(adapter_type)
|
||||
if not adapter_type:
|
||||
raise HTTPException(400, detail="不支持的适配器类型")
|
||||
if not isinstance(cfg, dict):
|
||||
raise HTTPException(400, detail="config 必须是对象")
|
||||
schema = schemas.get(adapter_type)
|
||||
@@ -81,7 +84,6 @@ async def available_adapter_types(
|
||||
for t, fields in get_config_schemas().items():
|
||||
data.append({
|
||||
"type": t,
|
||||
"name": "本地文件系统" if t == "local" else ("WebDAV" if t == "webdav" else t),
|
||||
"config_schema": fields,
|
||||
})
|
||||
return success(data)
|
||||
|
||||
@@ -10,6 +10,9 @@ from services.auth import (
|
||||
Token,
|
||||
get_current_active_user,
|
||||
User,
|
||||
request_password_reset,
|
||||
verify_password_reset_token,
|
||||
reset_password_with_token,
|
||||
)
|
||||
from pydantic import BaseModel
|
||||
from datetime import timedelta
|
||||
@@ -66,7 +69,7 @@ async def get_me(current_user: Annotated[User, Depends(get_current_active_user)]
|
||||
"""
|
||||
email = (current_user.email or "").strip().lower()
|
||||
md5_hash = hashlib.md5(email.encode("utf-8")).hexdigest()
|
||||
gravatar_url = f"https://www.gravatar.com/avatar/{md5_hash}?s=64&d=identicon"
|
||||
gravatar_url = f"https://cn.cravatar.com/avatar/{md5_hash}?s=64&d=identicon"
|
||||
return success({
|
||||
"id": current_user.id,
|
||||
"username": current_user.username,
|
||||
@@ -83,6 +86,15 @@ class UpdateMeRequest(BaseModel):
|
||||
new_password: str | None = None
|
||||
|
||||
|
||||
class PasswordResetRequest(BaseModel):
|
||||
email: str
|
||||
|
||||
|
||||
class PasswordResetConfirm(BaseModel):
|
||||
token: str
|
||||
password: str
|
||||
|
||||
|
||||
@router.put("/me", summary="更新当前登录用户信息")
|
||||
async def update_me(
|
||||
payload: UpdateMeRequest,
|
||||
@@ -120,3 +132,24 @@ async def update_me(
|
||||
"full_name": db_user.full_name,
|
||||
"gravatar_url": gravatar_url,
|
||||
})
|
||||
|
||||
|
||||
@router.post("/password-reset/request", summary="请求密码重置邮件")
|
||||
async def password_reset_request_endpoint(payload: PasswordResetRequest):
|
||||
await request_password_reset(payload.email)
|
||||
return success(msg="如果邮箱存在,将发送重置邮件")
|
||||
|
||||
|
||||
@router.get("/password-reset/verify", summary="校验密码重置令牌")
|
||||
async def password_reset_verify(token: str):
|
||||
user = await verify_password_reset_token(token)
|
||||
return success({
|
||||
"username": user.username,
|
||||
"email": user.email,
|
||||
})
|
||||
|
||||
|
||||
@router.post("/password-reset/confirm", summary="使用令牌重置密码")
|
||||
async def password_reset_confirm(payload: PasswordResetConfirm):
|
||||
await reset_password_with_token(payload.token, payload.password)
|
||||
return success(msg="密码已重置")
|
||||
|
||||
@@ -37,10 +37,13 @@ async def get_all_config(
|
||||
|
||||
@router.get("/status")
|
||||
async def get_system_status():
|
||||
logo = await ConfigCenter.get("APP_LOGO", "/logo.svg")
|
||||
favicon = await ConfigCenter.get("APP_FAVICON", logo)
|
||||
system_info = {
|
||||
"version": VERSION,
|
||||
"title": await ConfigCenter.get("APP_NAME", "Foxel"),
|
||||
"logo": await ConfigCenter.get("APP_LOGO", "/logo.svg"),
|
||||
"logo": logo,
|
||||
"favicon": favicon,
|
||||
"is_initialized": await has_users(),
|
||||
"app_domain": await ConfigCenter.get("APP_DOMAIN"),
|
||||
"file_domain": await ConfigCenter.get("FILE_DOMAIN"),
|
||||
|
||||
92
api/routes/email.py
Normal file
92
api/routes/email.py
Normal file
@@ -0,0 +1,92 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from services.auth import User, get_current_active_user
|
||||
from services.email import EmailService, EmailTemplateRenderer
|
||||
from schemas.email import EmailTestRequest, EmailTemplateUpdate, EmailTemplatePreviewPayload
|
||||
from api.response import success
|
||||
from services.logging import LogService
|
||||
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/email",
|
||||
tags=["email"],
|
||||
)
|
||||
|
||||
|
||||
@router.post("/test")
|
||||
async def trigger_test_email(
|
||||
payload: EmailTestRequest,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
):
|
||||
try:
|
||||
task = await EmailService.enqueue_email(
|
||||
recipients=[str(payload.to)],
|
||||
subject=payload.subject,
|
||||
template=payload.template,
|
||||
context=payload.context,
|
||||
)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
await LogService.action(
|
||||
"route:email",
|
||||
"Triggered email test",
|
||||
details={"task_id": task.id, "template": payload.template, "to": str(payload.to)},
|
||||
user_id=getattr(current_user, "id", None),
|
||||
)
|
||||
return success({"task_id": task.id})
|
||||
|
||||
|
||||
@router.get("/templates")
|
||||
async def list_email_templates(
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
):
|
||||
templates = await EmailTemplateRenderer.list_templates()
|
||||
return success({"templates": templates})
|
||||
|
||||
|
||||
@router.get("/templates/{name}")
|
||||
async def get_email_template(
|
||||
name: str,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
):
|
||||
try:
|
||||
content = await EmailTemplateRenderer.load(name)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(status_code=404, detail="模板不存在")
|
||||
return success({"name": name, "content": content})
|
||||
|
||||
|
||||
@router.post("/templates/{name}")
|
||||
async def update_email_template(
|
||||
name: str,
|
||||
payload: EmailTemplateUpdate,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
):
|
||||
try:
|
||||
await EmailTemplateRenderer.save(name, payload.content)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
await LogService.action(
|
||||
"route:email",
|
||||
"Updated email template",
|
||||
details={"template": name},
|
||||
user_id=getattr(current_user, "id", None),
|
||||
)
|
||||
return success({"name": name})
|
||||
|
||||
|
||||
@router.post("/templates/{name}/preview")
|
||||
async def preview_email_template(
|
||||
name: str,
|
||||
payload: EmailTemplatePreviewPayload,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
):
|
||||
try:
|
||||
html = await EmailTemplateRenderer.render(name, payload.context)
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(status_code=404, detail="模板不存在")
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
return success({"html": html})
|
||||
543
api/routes/s3.py
Normal file
543
api/routes/s3.py
Normal file
@@ -0,0 +1,543 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import datetime as dt
|
||||
import hashlib
|
||||
import hmac
|
||||
import uuid
|
||||
from typing import Dict, Iterable, List, Optional, Tuple
|
||||
|
||||
from fastapi import APIRouter, Request, Response
|
||||
from fastapi import HTTPException
|
||||
|
||||
from services.config import ConfigCenter
|
||||
from services.virtual_fs import (
|
||||
delete_path,
|
||||
list_virtual_dir,
|
||||
stat_file,
|
||||
stream_file,
|
||||
write_file_stream,
|
||||
)
|
||||
|
||||
|
||||
router = APIRouter(prefix="/s3", tags=["s3"])
|
||||
|
||||
|
||||
FALSEY = {"0", "false", "off", "no"}
|
||||
_XML_NS = "http://s3.amazonaws.com/doc/2006-03-01/"
|
||||
|
||||
|
||||
class S3Settings(Dict[str, str]):
|
||||
bucket: str
|
||||
region: str
|
||||
base_path: str
|
||||
access_key: str
|
||||
secret_key: str
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return dt.datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%S.000Z")
|
||||
|
||||
|
||||
def _etag(key: str, size: Optional[int], mtime: Optional[int]) -> str:
|
||||
raw = f"{key}|{size or 0}|{mtime or 0}".encode("utf-8")
|
||||
return '"' + hashlib.md5(raw).hexdigest() + '"'
|
||||
|
||||
|
||||
def _meta_headers() -> Tuple[str, Dict[str, str]]:
|
||||
req_id = uuid.uuid4().hex
|
||||
headers = {
|
||||
"x-amz-request-id": req_id,
|
||||
"x-amz-id-2": uuid.uuid4().hex,
|
||||
"Server": "FoxelS3",
|
||||
}
|
||||
return req_id, headers
|
||||
|
||||
|
||||
def _s3_error(code: str, message: str, resource: str = "", status: int = 400) -> Response:
|
||||
req_id, headers = _meta_headers()
|
||||
xml = (
|
||||
f"<?xml version=\"1.0\" encoding=\"UTF-8\"?>"
|
||||
f"<Error>"
|
||||
f"<Code>{code}</Code>"
|
||||
f"<Message>{message}</Message>"
|
||||
f"<Resource>{resource}</Resource>"
|
||||
f"<RequestId>{req_id}</RequestId>"
|
||||
f"</Error>"
|
||||
)
|
||||
return Response(content=xml, status_code=status, media_type="application/xml", headers=headers)
|
||||
|
||||
|
||||
async def _ensure_enabled() -> Optional[Response]:
|
||||
flag = await ConfigCenter.get("S3_MAPPING_ENABLED", "1")
|
||||
if str(flag).strip().lower() in FALSEY:
|
||||
return _s3_error("ServiceUnavailable", "S3 mapping disabled", status=503)
|
||||
return None
|
||||
|
||||
|
||||
async def _get_settings() -> Tuple[Optional[S3Settings], Optional[Response]]:
|
||||
bucket = (await ConfigCenter.get("S3_MAPPING_BUCKET", "foxel")) or "foxel"
|
||||
region = (await ConfigCenter.get("S3_MAPPING_REGION", "us-east-1")) or "us-east-1"
|
||||
base_path = (await ConfigCenter.get("S3_MAPPING_BASE_PATH", "/")) or "/"
|
||||
access_key = (await ConfigCenter.get("S3_MAPPING_ACCESS_KEY")) or ""
|
||||
secret_key = (await ConfigCenter.get("S3_MAPPING_SECRET_KEY")) or ""
|
||||
if not access_key or not secret_key:
|
||||
return None, _s3_error(
|
||||
"InvalidAccessKeyId",
|
||||
"S3 mapping access key/secret are not configured.",
|
||||
status=403,
|
||||
)
|
||||
settings: S3Settings = {
|
||||
"bucket": bucket,
|
||||
"region": region,
|
||||
"base_path": base_path,
|
||||
"access_key": access_key,
|
||||
"secret_key": secret_key,
|
||||
}
|
||||
return settings, None
|
||||
|
||||
|
||||
def _canonical_uri(path: str) -> str:
|
||||
from urllib.parse import quote
|
||||
|
||||
if not path:
|
||||
return "/"
|
||||
return quote(path, safe="/-_.~")
|
||||
|
||||
|
||||
def _canonical_query(params: Iterable[Tuple[str, str]]) -> str:
|
||||
from urllib.parse import quote
|
||||
|
||||
encoded = []
|
||||
for key, value in params:
|
||||
enc_key = quote(key, safe="-_.~")
|
||||
enc_val = quote(value or "", safe="-_.~")
|
||||
encoded.append((enc_key, enc_val))
|
||||
encoded.sort()
|
||||
return "&".join(f"{k}={v}" for k, v in encoded)
|
||||
|
||||
|
||||
def _normalize_ws(value: str) -> str:
|
||||
return " ".join(value.strip().split())
|
||||
|
||||
|
||||
def _sign(key: bytes, msg: str) -> bytes:
|
||||
return hmac.new(key, msg.encode("utf-8"), hashlib.sha256).digest()
|
||||
|
||||
|
||||
async def _authorize_sigv4(request: Request, settings: S3Settings) -> Optional[Response]:
|
||||
auth = request.headers.get("authorization")
|
||||
if not auth:
|
||||
return _s3_error("AccessDenied", "Missing Authorization header", status=403)
|
||||
scheme = "AWS4-HMAC-SHA256"
|
||||
if not auth.startswith(scheme + " "):
|
||||
return _s3_error("InvalidRequest", "Signature Version 4 is required", status=400)
|
||||
|
||||
parts: Dict[str, str] = {}
|
||||
for segment in auth[len(scheme) + 1 :].split(","):
|
||||
k, _, v = segment.strip().partition("=")
|
||||
parts[k] = v
|
||||
|
||||
credential = parts.get("Credential")
|
||||
signed_headers = parts.get("SignedHeaders")
|
||||
signature = parts.get("Signature")
|
||||
if not credential or not signed_headers or not signature:
|
||||
return _s3_error("InvalidRequest", "Authorization header is malformed", status=400)
|
||||
|
||||
cred_parts = credential.split("/")
|
||||
if len(cred_parts) != 5 or cred_parts[-1] != "aws4_request":
|
||||
return _s3_error("InvalidRequest", "Credential scope is invalid", status=400)
|
||||
|
||||
access_key, datestamp, region, service, _ = cred_parts
|
||||
if access_key != settings["access_key"]:
|
||||
return _s3_error("InvalidAccessKeyId", "The AWS Access Key Id you provided does not exist in our records.", status=403)
|
||||
if service != "s3":
|
||||
return _s3_error("InvalidRequest", "Only service 's3' is supported", status=400)
|
||||
if region != settings["region"]:
|
||||
return _s3_error("AuthorizationHeaderMalformed", f"Region '{region}' is invalid", status=400)
|
||||
|
||||
amz_date = request.headers.get("x-amz-date")
|
||||
if not amz_date or not amz_date.startswith(datestamp):
|
||||
return _s3_error("AuthorizationHeaderMalformed", "x-amz-date does not match credential scope", status=400)
|
||||
|
||||
payload_hash = request.headers.get("x-amz-content-sha256")
|
||||
if not payload_hash:
|
||||
return _s3_error("AuthorizationHeaderMalformed", "Missing x-amz-content-sha256", status=400)
|
||||
if payload_hash.upper().startswith("STREAMING-AWS4-HMAC-SHA256"):
|
||||
return _s3_error("NotImplemented", "Chunked uploads are not supported", status=400)
|
||||
|
||||
signed_header_names = [h.strip().lower() for h in signed_headers.split(";") if h.strip()]
|
||||
headers = {k.lower(): v for k, v in request.headers.items()}
|
||||
canonical_headers = []
|
||||
for name in signed_header_names:
|
||||
value = headers.get(name)
|
||||
if value is None:
|
||||
return _s3_error("AuthorizationHeaderMalformed", f"Signed header '{name}' missing", status=400)
|
||||
canonical_headers.append(f"{name}:{_normalize_ws(value)}\n")
|
||||
|
||||
canonical_request = "\n".join(
|
||||
[
|
||||
request.method,
|
||||
_canonical_uri(request.url.path),
|
||||
_canonical_query(request.query_params.multi_items()),
|
||||
"".join(canonical_headers),
|
||||
";".join(signed_header_names),
|
||||
payload_hash,
|
||||
]
|
||||
)
|
||||
|
||||
hashed_request = hashlib.sha256(canonical_request.encode("utf-8")).hexdigest()
|
||||
scope = "/".join([datestamp, region, "s3", "aws4_request"])
|
||||
string_to_sign = "\n".join([scheme, amz_date, scope, hashed_request])
|
||||
|
||||
k_date = _sign(("AWS4" + settings["secret_key"]).encode("utf-8"), datestamp)
|
||||
k_region = hmac.new(k_date, region.encode("utf-8"), hashlib.sha256).digest()
|
||||
k_service = hmac.new(k_region, b"s3", hashlib.sha256).digest()
|
||||
k_signing = hmac.new(k_service, b"aws4_request", hashlib.sha256).digest()
|
||||
expected = hmac.new(k_signing, string_to_sign.encode("utf-8"), hashlib.sha256).hexdigest()
|
||||
if expected != signature:
|
||||
return _s3_error("SignatureDoesNotMatch", "The request signature we calculated does not match the signature you provided.", status=403)
|
||||
return None
|
||||
|
||||
|
||||
def _virtual_path(settings: S3Settings, key: str) -> str:
|
||||
key_norm = key.strip("/")
|
||||
base_norm = settings["base_path"].strip("/")
|
||||
segments = [seg for seg in [base_norm, key_norm] if seg]
|
||||
if not segments:
|
||||
return "/"
|
||||
return "/" + "/".join(segments)
|
||||
|
||||
|
||||
def _join_virtual(base: str, name: str) -> str:
|
||||
if not base or base == "/":
|
||||
return "/" + name.strip("/")
|
||||
return base.rstrip("/") + "/" + name.strip("/")
|
||||
|
||||
|
||||
async def _list_dir_all(path: str) -> List[Dict]:
|
||||
items: List[Dict] = []
|
||||
page_num = 1
|
||||
page_size = 1000
|
||||
while True:
|
||||
try:
|
||||
res = await list_virtual_dir(path, page_num=page_num, page_size=page_size)
|
||||
except HTTPException as exc: # directory missing
|
||||
if exc.status_code in (400, 404):
|
||||
return []
|
||||
raise
|
||||
chunk = res.get("items", [])
|
||||
items.extend(chunk)
|
||||
total = int(res.get("total", len(items)))
|
||||
if len(items) >= total or not chunk or len(chunk) < page_size:
|
||||
break
|
||||
page_num += 1
|
||||
return items
|
||||
|
||||
|
||||
async def _collect_objects(path: str, key_prefix: str, recursive: bool, collect_prefixes: bool) -> Tuple[List[Tuple[str, Dict]], List[str]]:
|
||||
entries = await _list_dir_all(path)
|
||||
files: List[Tuple[str, Dict]] = []
|
||||
prefixes: List[str] = []
|
||||
for entry in entries:
|
||||
name = entry.get("name")
|
||||
if not name:
|
||||
continue
|
||||
if entry.get("is_dir"):
|
||||
dir_key = f"{key_prefix}{name.strip('/')}/"
|
||||
if collect_prefixes:
|
||||
prefixes.append(dir_key)
|
||||
if recursive:
|
||||
sub_path = _join_virtual(path, name)
|
||||
sub_files, _ = await _collect_objects(sub_path, dir_key, True, False)
|
||||
files.extend(sub_files)
|
||||
else:
|
||||
key = f"{key_prefix}{name}"
|
||||
files.append((key, entry))
|
||||
files.sort(key=lambda item: item[0])
|
||||
prefixes.sort()
|
||||
return files, prefixes
|
||||
|
||||
|
||||
def _encode_token(key: str) -> str:
|
||||
raw = base64.urlsafe_b64encode(key.encode("utf-8")).decode("ascii")
|
||||
return raw.rstrip("=")
|
||||
|
||||
|
||||
def _decode_token(token: str) -> Optional[str]:
|
||||
if not token:
|
||||
return None
|
||||
padding = "=" * (-len(token) % 4)
|
||||
try:
|
||||
return base64.urlsafe_b64decode(token + padding).decode("utf-8")
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _apply_pagination(entries: List[Tuple[str, Dict]], prefixes: List[str], max_keys: int, start_after: Optional[str], continuation_token: Optional[str]) -> Tuple[List[Tuple[str, Dict]], List[str], bool, Optional[str]]:
|
||||
combined = [(key, data, True) for key, data in entries] + [(prefix, None, False) for prefix in prefixes]
|
||||
combined.sort(key=lambda item: item[0])
|
||||
|
||||
start_key = start_after or _decode_token(continuation_token or "")
|
||||
if start_key:
|
||||
combined = [item for item in combined if item[0] > start_key]
|
||||
|
||||
is_truncated = len(combined) > max_keys
|
||||
sliced = combined[:max_keys]
|
||||
next_token = _encode_token(sliced[-1][0]) if is_truncated and sliced else None
|
||||
|
||||
contents = [(key, data) for key, data, is_file in sliced if is_file]
|
||||
next_prefixes = [key for key, _, is_file in sliced if not is_file]
|
||||
return contents, next_prefixes, is_truncated, next_token
|
||||
|
||||
|
||||
def _format_contents(entries: List[Tuple[str, Dict]]) -> str:
|
||||
blocks = []
|
||||
for key, meta in entries:
|
||||
size = int(meta.get("size", 0))
|
||||
mtime = meta.get("mtime")
|
||||
if mtime is not None:
|
||||
try:
|
||||
mtime_val = int(mtime)
|
||||
except Exception:
|
||||
mtime_val = 0
|
||||
else:
|
||||
mtime_val = 0
|
||||
last_modified = dt.datetime.utcfromtimestamp(mtime_val or dt.datetime.utcnow().timestamp()).strftime("%Y-%m-%dT%H:%M:%S.000Z")
|
||||
etag = _etag(key, size, mtime_val)
|
||||
blocks.append(
|
||||
f"<Contents><Key>{key}</Key><LastModified>{last_modified}</LastModified><ETag>{etag}</ETag><Size>{size}</Size><StorageClass>STANDARD</StorageClass></Contents>"
|
||||
)
|
||||
return "".join(blocks)
|
||||
|
||||
|
||||
def _format_common_prefixes(prefixes: List[str]) -> str:
|
||||
return "".join(f"<CommonPrefixes><Prefix>{p}</Prefix></CommonPrefixes>" for p in prefixes)
|
||||
|
||||
|
||||
def _resource_path(bucket: str, key: Optional[str] = None) -> str:
|
||||
if key:
|
||||
return f"/s3/{bucket}/{key}"
|
||||
return f"/s3/{bucket}"
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_buckets(request: Request):
|
||||
if (resp := await _ensure_enabled()) is not None:
|
||||
return resp
|
||||
settings, err = await _get_settings()
|
||||
if err:
|
||||
return err
|
||||
assert settings
|
||||
if (auth := await _authorize_sigv4(request, settings)) is not None:
|
||||
return auth
|
||||
req_id, headers = _meta_headers()
|
||||
xml = (
|
||||
f"<?xml version=\"1.0\" encoding=\"UTF-8\"?>"
|
||||
f"<ListAllMyBucketsResult xmlns=\"{_XML_NS}\">"
|
||||
f"<Owner><ID>{settings['access_key']}</ID><DisplayName>Foxel</DisplayName></Owner>"
|
||||
f"<Buckets><Bucket><Name>{settings['bucket']}</Name><CreationDate>{_now_iso()}</CreationDate></Bucket></Buckets>"
|
||||
f"</ListAllMyBucketsResult>"
|
||||
)
|
||||
headers.update({"Content-Type": "application/xml"})
|
||||
return Response(content=xml, media_type="application/xml", headers=headers)
|
||||
|
||||
|
||||
@router.get("/{bucket}")
|
||||
async def list_objects(request: Request, bucket: str):
|
||||
if (resp := await _ensure_enabled()) is not None:
|
||||
return resp
|
||||
settings, err = await _get_settings()
|
||||
if err:
|
||||
return err
|
||||
assert settings
|
||||
if bucket != settings["bucket"]:
|
||||
return _s3_error("NoSuchBucket", "The specified bucket does not exist.", _resource_path(bucket), status=404)
|
||||
if (auth := await _authorize_sigv4(request, settings)) is not None:
|
||||
return auth
|
||||
|
||||
params = request.query_params
|
||||
if params.get("list-type", "2") != "2":
|
||||
return _s3_error("InvalidArgument", "Only ListObjectsV2 (list-type=2) is supported.", _resource_path(bucket), status=400)
|
||||
|
||||
prefix = (params.get("prefix") or "").lstrip("/")
|
||||
delimiter = params.get("delimiter")
|
||||
recursive = not delimiter
|
||||
max_keys_raw = params.get("max-keys", "1000")
|
||||
try:
|
||||
max_keys = max(1, min(1000, int(max_keys_raw)))
|
||||
except ValueError:
|
||||
max_keys = 1000
|
||||
start_after = (params.get("start-after") or "").lstrip("/") or None
|
||||
continuation = params.get("continuation-token")
|
||||
|
||||
# Exact file match if prefix is non-empty and does not end with '/'
|
||||
files: List[Tuple[str, Dict]] = []
|
||||
prefixes: List[str] = []
|
||||
if prefix and not prefix.endswith("/"):
|
||||
try:
|
||||
info = await stat_file(_virtual_path(settings, prefix))
|
||||
if not info.get("is_dir"):
|
||||
files = [(prefix, info)]
|
||||
except HTTPException as exc:
|
||||
if exc.status_code not in (400, 404):
|
||||
raise
|
||||
if files:
|
||||
contents, next_prefixes, is_truncated, next_token = _apply_pagination(files, [], max_keys, start_after, continuation)
|
||||
xml = _build_list_result(bucket, prefix, delimiter, contents, next_prefixes, max_keys, is_truncated, continuation, next_token, start_after)
|
||||
return xml
|
||||
|
||||
dir_prefix = prefix if not prefix or prefix.endswith("/") else prefix + "/"
|
||||
virtual_dir = _virtual_path(settings, dir_prefix)
|
||||
files, prefixes = await _collect_objects(virtual_dir, dir_prefix, recursive, bool(delimiter))
|
||||
|
||||
contents, next_prefixes, is_truncated, next_token = _apply_pagination(files, prefixes if delimiter else [], max_keys, start_after, continuation)
|
||||
return _build_list_result(bucket, prefix, delimiter, contents, next_prefixes if delimiter else [], max_keys, is_truncated, continuation, next_token, start_after)
|
||||
|
||||
|
||||
@router.get("/{bucket}/", include_in_schema=False)
|
||||
async def list_objects_with_slash(request: Request, bucket: str):
|
||||
return await list_objects(request, bucket)
|
||||
|
||||
|
||||
def _build_list_result(
|
||||
bucket: str,
|
||||
prefix: str,
|
||||
delimiter: Optional[str],
|
||||
contents: List[Tuple[str, Dict]],
|
||||
prefixes: List[str],
|
||||
max_keys: int,
|
||||
is_truncated: bool,
|
||||
continuation: Optional[str],
|
||||
next_token: Optional[str],
|
||||
start_after: Optional[str],
|
||||
):
|
||||
req_id, headers = _meta_headers()
|
||||
body = [f"<?xml version=\"1.0\" encoding=\"UTF-8\"?>", f"<ListBucketResult xmlns=\"{_XML_NS}\">"]
|
||||
body.append(f"<Name>{bucket}</Name>")
|
||||
body.append(f"<Prefix>{prefix}</Prefix>")
|
||||
if delimiter:
|
||||
body.append(f"<Delimiter>{delimiter}</Delimiter>")
|
||||
if continuation:
|
||||
body.append(f"<ContinuationToken>{continuation}</ContinuationToken>")
|
||||
if start_after:
|
||||
body.append(f"<StartAfter>{start_after}</StartAfter>")
|
||||
body.append(f"<MaxKeys>{max_keys}</MaxKeys>")
|
||||
body.append(f"<KeyCount>{len(contents) + len(prefixes)}</KeyCount>")
|
||||
body.append(f"<IsTruncated>{str(is_truncated).lower()}</IsTruncated>")
|
||||
if next_token:
|
||||
body.append(f"<NextContinuationToken>{next_token}</NextContinuationToken>")
|
||||
body.append(_format_contents(contents))
|
||||
if prefixes:
|
||||
body.append(_format_common_prefixes(prefixes))
|
||||
body.append("</ListBucketResult>")
|
||||
xml = "".join(body)
|
||||
headers.update({"Content-Type": "application/xml"})
|
||||
return Response(content=xml, media_type="application/xml", headers=headers)
|
||||
|
||||
|
||||
async def _ensure_bucket_and_auth(request: Request, bucket: str) -> Tuple[Optional[S3Settings], Optional[Response]]:
|
||||
if (resp := await _ensure_enabled()) is not None:
|
||||
return None, resp
|
||||
settings, err = await _get_settings()
|
||||
if err:
|
||||
return None, err
|
||||
assert settings
|
||||
if bucket != settings["bucket"]:
|
||||
return None, _s3_error("NoSuchBucket", "The specified bucket does not exist.", _resource_path(bucket), status=404)
|
||||
if (auth := await _authorize_sigv4(request, settings)) is not None:
|
||||
return None, auth
|
||||
return settings, None
|
||||
|
||||
|
||||
def _object_headers(meta: Dict, key: str) -> Dict[str, str]:
|
||||
size = int(meta.get("size", 0))
|
||||
mtime = meta.get("mtime")
|
||||
if mtime is not None:
|
||||
try:
|
||||
mtime_val = int(mtime)
|
||||
except Exception:
|
||||
mtime_val = 0
|
||||
else:
|
||||
mtime_val = 0
|
||||
last_modified = dt.datetime.utcfromtimestamp(mtime_val or dt.datetime.utcnow().timestamp()).strftime("%a, %d %b %Y %H:%M:%S GMT")
|
||||
headers = {
|
||||
"Content-Length": str(size),
|
||||
"ETag": _etag(key, size, mtime_val),
|
||||
"Last-Modified": last_modified,
|
||||
"Accept-Ranges": "bytes",
|
||||
"x-amz-version-id": "null",
|
||||
}
|
||||
return headers
|
||||
|
||||
|
||||
async def _stat_object(settings: S3Settings, key: str) -> Tuple[Optional[Dict], Optional[Response]]:
|
||||
try:
|
||||
info = await stat_file(_virtual_path(settings, key))
|
||||
if info.get("is_dir"):
|
||||
return None, _s3_error("NoSuchKey", "The specified key does not exist.", _resource_path(settings["bucket"], key), status=404)
|
||||
return info, None
|
||||
except HTTPException as exc:
|
||||
if exc.status_code == 404:
|
||||
return None, _s3_error("NoSuchKey", "The specified key does not exist.", _resource_path(settings["bucket"], key), status=404)
|
||||
raise
|
||||
|
||||
|
||||
@router.api_route("/{bucket}/{object_path:path}", methods=["GET", "HEAD"])
|
||||
async def object_get_head(request: Request, bucket: str, object_path: str):
|
||||
settings, error = await _ensure_bucket_and_auth(request, bucket)
|
||||
if error:
|
||||
return error
|
||||
assert settings
|
||||
key = object_path.lstrip("/")
|
||||
meta, err = await _stat_object(settings, key)
|
||||
if err:
|
||||
return err
|
||||
assert meta
|
||||
_, base_headers = _meta_headers()
|
||||
base_headers.update(_object_headers(meta, key))
|
||||
if request.method == "HEAD":
|
||||
return Response(status_code=200, headers=base_headers)
|
||||
resp = await stream_file(_virtual_path(settings, key), request.headers.get("range"))
|
||||
safe_merge_keys = {"ETag", "Last-Modified", "x-amz-version-id", "Accept-Ranges"}
|
||||
for hk, hv in base_headers.items():
|
||||
if hk in safe_merge_keys:
|
||||
resp.headers.setdefault(hk, hv)
|
||||
resp.headers.setdefault("Content-Type", meta.get("mime") or "application/octet-stream")
|
||||
return resp
|
||||
|
||||
|
||||
@router.put("/{bucket}/{object_path:path}")
|
||||
async def put_object(request: Request, bucket: str, object_path: str):
|
||||
settings, error = await _ensure_bucket_and_auth(request, bucket)
|
||||
if error:
|
||||
return error
|
||||
assert settings
|
||||
key = object_path.lstrip("/")
|
||||
await write_file_stream(_virtual_path(settings, key), request.stream(), overwrite=True)
|
||||
meta, err = await _stat_object(settings, key)
|
||||
if err:
|
||||
return err
|
||||
headers = _object_headers(meta, key)
|
||||
headers.pop("Content-Length", None)
|
||||
headers.pop("Accept-Ranges", None)
|
||||
headers["Content-Length"] = "0"
|
||||
_, extra = _meta_headers()
|
||||
headers.update(extra)
|
||||
return Response(status_code=200, headers=headers)
|
||||
|
||||
|
||||
@router.delete("/{bucket}/{object_path:path}")
|
||||
async def delete_object(request: Request, bucket: str, object_path: str):
|
||||
settings, error = await _ensure_bucket_and_auth(request, bucket)
|
||||
if error:
|
||||
return error
|
||||
assert settings
|
||||
key = object_path.lstrip("/")
|
||||
try:
|
||||
await delete_path(_virtual_path(settings, key))
|
||||
except HTTPException as exc:
|
||||
if exc.status_code not in (400, 404):
|
||||
raise
|
||||
_, headers = _meta_headers()
|
||||
return Response(status_code=204, headers=headers)
|
||||
@@ -20,6 +20,16 @@ from services.virtual_fs import (
|
||||
copy_path,
|
||||
stream_file,
|
||||
)
|
||||
from services.config import ConfigCenter
|
||||
|
||||
|
||||
_WEBDAV_ENABLED_KEY = "WEBDAV_MAPPING_ENABLED"
|
||||
|
||||
|
||||
async def _ensure_webdav_enabled() -> None:
|
||||
enabled = await ConfigCenter.get(_WEBDAV_ENABLED_KEY, "1")
|
||||
if str(enabled).strip().lower() in ("0", "false", "off", "no"):
|
||||
raise HTTPException(503, detail="WebDAV mapping disabled")
|
||||
|
||||
|
||||
router = APIRouter(prefix="/webdav", tags=["webdav"])
|
||||
@@ -140,12 +150,17 @@ def _normalize_fs_path(path: str) -> str:
|
||||
|
||||
|
||||
@router.options("/{path:path}")
|
||||
async def options_root(path: str = ""):
|
||||
async def options_root(path: str = "", _enabled: None = Depends(_ensure_webdav_enabled)):
|
||||
return Response(status_code=200, headers=_dav_headers())
|
||||
|
||||
|
||||
@router.api_route("/{path:path}", methods=["PROPFIND"])
|
||||
async def propfind(request: Request, path: str, user: User = Depends(_get_basic_user)):
|
||||
async def propfind(
|
||||
request: Request,
|
||||
path: str,
|
||||
_enabled: None = Depends(_ensure_webdav_enabled),
|
||||
user: User = Depends(_get_basic_user),
|
||||
):
|
||||
full_path = _normalize_fs_path(path)
|
||||
depth = request.headers.get("Depth", "1").lower()
|
||||
if depth not in ("0", "1", "infinity"):
|
||||
@@ -187,14 +202,23 @@ async def propfind(request: Request, path: str, user: User = Depends(_get_basic_
|
||||
|
||||
|
||||
@router.get("/{path:path}")
|
||||
async def dav_get(path: str, request: Request, user: User = Depends(_get_basic_user)):
|
||||
async def dav_get(
|
||||
path: str,
|
||||
request: Request,
|
||||
_enabled: None = Depends(_ensure_webdav_enabled),
|
||||
user: User = Depends(_get_basic_user),
|
||||
):
|
||||
full_path = _normalize_fs_path(path)
|
||||
range_header = request.headers.get("Range")
|
||||
return await stream_file(full_path, range_header)
|
||||
|
||||
|
||||
@router.head("/{path:path}")
|
||||
async def dav_head(path: str, user: User = Depends(_get_basic_user)):
|
||||
async def dav_head(
|
||||
path: str,
|
||||
_enabled: None = Depends(_ensure_webdav_enabled),
|
||||
user: User = Depends(_get_basic_user),
|
||||
):
|
||||
full_path = _normalize_fs_path(path)
|
||||
try:
|
||||
st = await stat_file(full_path)
|
||||
@@ -216,7 +240,12 @@ async def dav_head(path: str, user: User = Depends(_get_basic_user)):
|
||||
|
||||
|
||||
@router.api_route("/{path:path}", methods=["PUT"])
|
||||
async def dav_put(path: str, request: Request, user: User = Depends(_get_basic_user)):
|
||||
async def dav_put(
|
||||
path: str,
|
||||
request: Request,
|
||||
_enabled: None = Depends(_ensure_webdav_enabled),
|
||||
user: User = Depends(_get_basic_user),
|
||||
):
|
||||
full_path = _normalize_fs_path(path)
|
||||
async def body_iter():
|
||||
async for chunk in request.stream():
|
||||
@@ -227,14 +256,22 @@ async def dav_put(path: str, request: Request, user: User = Depends(_get_basic_u
|
||||
|
||||
|
||||
@router.api_route("/{path:path}", methods=["DELETE"])
|
||||
async def dav_delete(path: str, user: User = Depends(_get_basic_user)):
|
||||
async def dav_delete(
|
||||
path: str,
|
||||
_enabled: None = Depends(_ensure_webdav_enabled),
|
||||
user: User = Depends(_get_basic_user),
|
||||
):
|
||||
full_path = _normalize_fs_path(path)
|
||||
await delete_path(full_path)
|
||||
return Response(status_code=204, headers=_dav_headers())
|
||||
|
||||
|
||||
@router.api_route("/{path:path}", methods=["MKCOL"])
|
||||
async def dav_mkcol(path: str, user: User = Depends(_get_basic_user)):
|
||||
async def dav_mkcol(
|
||||
path: str,
|
||||
_enabled: None = Depends(_ensure_webdav_enabled),
|
||||
user: User = Depends(_get_basic_user),
|
||||
):
|
||||
full_path = _normalize_fs_path(path)
|
||||
await make_dir(full_path)
|
||||
return Response(status_code=201, headers=_dav_headers())
|
||||
@@ -270,4 +307,3 @@ async def dav_copy(path: str, request: Request, user: User = Depends(_get_basic_
|
||||
overwrite = request.headers.get("Overwrite", "T").upper() != "F"
|
||||
await copy_path(full_src, dst, overwrite=overwrite)
|
||||
return Response(status_code=201 if not overwrite else 204, headers=_dav_headers())
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ http {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
location ~ ^/(api|webdav|docs|openapi\.json$) {
|
||||
location ~ ^/(api|webdav|s3|docs|openapi\.json$) {
|
||||
proxy_pass http://127.0.0.1:8000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
|
||||
@@ -22,4 +22,5 @@ dependencies = [
|
||||
"uvicorn>=0.37.0",
|
||||
"pymilvus[milvus-lite]>=2.6.2",
|
||||
"paramiko>=4.0.0",
|
||||
"pydantic[email]>=2.11.7",
|
||||
]
|
||||
|
||||
@@ -1,15 +1,28 @@
|
||||
import re
|
||||
from typing import Dict, Optional
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
|
||||
class AdapterBase(BaseModel):
|
||||
name: str
|
||||
type: str = Field(pattern=r"^[a-zA-Z0-9_]+$")
|
||||
type: str = Field(pattern=r"^[a-z0-9_]+$")
|
||||
config: Dict = Field(default_factory=dict)
|
||||
enabled: bool = True
|
||||
path: str = None
|
||||
sub_path: Optional[str] = None
|
||||
|
||||
@field_validator("type", mode="before")
|
||||
@classmethod
|
||||
def _normalize_type(cls, v: str):
|
||||
if not isinstance(v, str):
|
||||
raise ValueError("type required")
|
||||
normalized = v.strip().lower()
|
||||
if not normalized:
|
||||
raise ValueError("type required")
|
||||
if not re.fullmatch(r"[a-z0-9_]+", normalized):
|
||||
raise ValueError("type must be lowercase alphanumeric or underscore")
|
||||
return normalized
|
||||
|
||||
|
||||
class AdapterCreate(AdapterBase):
|
||||
@staticmethod
|
||||
|
||||
18
schemas/email.py
Normal file
18
schemas/email.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from typing import Any, Dict
|
||||
|
||||
from pydantic import BaseModel, EmailStr, Field
|
||||
|
||||
|
||||
class EmailTestRequest(BaseModel):
|
||||
to: EmailStr
|
||||
subject: str = Field(..., min_length=1)
|
||||
template: str = Field(default="test", min_length=1)
|
||||
context: Dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class EmailTemplateUpdate(BaseModel):
|
||||
content: str
|
||||
|
||||
|
||||
class EmailTemplatePreviewPayload(BaseModel):
|
||||
context: Dict[str, Any] = Field(default_factory=dict)
|
||||
560
services/adapters/googledrive.py
Normal file
560
services/adapters/googledrive.py
Normal file
@@ -0,0 +1,560 @@
|
||||
from __future__ import annotations
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from typing import List, Dict, Tuple, AsyncIterator
|
||||
import httpx
|
||||
from fastapi.responses import StreamingResponse, Response
|
||||
from fastapi import HTTPException
|
||||
from models import StorageAdapter
|
||||
|
||||
GOOGLE_OAUTH_URL = "https://oauth2.googleapis.com/token"
|
||||
GOOGLE_DRIVE_API_URL = "https://www.googleapis.com/drive/v3"
|
||||
|
||||
|
||||
class GoogleDriveAdapter:
|
||||
"""Google Drive 存储适配器"""
|
||||
|
||||
def __init__(self, record: StorageAdapter):
|
||||
self.record = record
|
||||
cfg = record.config
|
||||
self.client_id = cfg.get("client_id")
|
||||
self.client_secret = cfg.get("client_secret")
|
||||
self.refresh_token = cfg.get("refresh_token")
|
||||
self.root_folder_id = cfg.get("root_folder_id", "root")
|
||||
self.enable_redirect_307 = bool(cfg.get("enable_direct_download_307"))
|
||||
|
||||
if not all([self.client_id, self.client_secret, self.refresh_token]):
|
||||
raise ValueError(
|
||||
"Google Drive 适配器需要 client_id, client_secret, 和 refresh_token")
|
||||
|
||||
self._access_token: str | None = None
|
||||
self._token_expiry: datetime | None = None
|
||||
|
||||
def get_effective_root(self, sub_path: str | None) -> str:
|
||||
"""
|
||||
获取有效根路径。
|
||||
:param sub_path: 子路径。
|
||||
:return: 完整的有效路径。
|
||||
"""
|
||||
if sub_path:
|
||||
return f"{sub_path.strip('/')}".strip()
|
||||
return ""
|
||||
|
||||
async def _get_access_token(self) -> str:
|
||||
"""
|
||||
获取或刷新 access token。
|
||||
:return: access token。
|
||||
"""
|
||||
if self._access_token and self._token_expiry and datetime.now(timezone.utc) < self._token_expiry:
|
||||
return self._access_token
|
||||
|
||||
data = {
|
||||
"client_id": self.client_id,
|
||||
"client_secret": self.client_secret,
|
||||
"refresh_token": self.refresh_token,
|
||||
"grant_type": "refresh_token",
|
||||
}
|
||||
async with httpx.AsyncClient(timeout=20.0) as client:
|
||||
resp = await client.post(GOOGLE_OAUTH_URL, data=data)
|
||||
resp.raise_for_status()
|
||||
token_data = resp.json()
|
||||
self._access_token = token_data["access_token"]
|
||||
self._token_expiry = datetime.now(
|
||||
timezone.utc) + timedelta(seconds=token_data["expires_in"] - 300)
|
||||
return self._access_token
|
||||
|
||||
async def _request(self, method: str, endpoint: str, **kwargs):
|
||||
"""
|
||||
向 Google Drive API 发送请求。
|
||||
:param method: HTTP 方法。
|
||||
:param endpoint: API 端点。
|
||||
:param kwargs: 其他请求参数。
|
||||
:return: 响应对象。
|
||||
"""
|
||||
token = await self._get_access_token()
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
if "headers" in kwargs:
|
||||
headers.update(kwargs.pop("headers"))
|
||||
|
||||
url = f"{GOOGLE_DRIVE_API_URL}{endpoint}"
|
||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||
resp = await client.request(method, url, headers=headers, **kwargs)
|
||||
if resp.status_code == 401:
|
||||
self._access_token = None
|
||||
token = await self._get_access_token()
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
resp = await client.request(method, url, headers=headers, **kwargs)
|
||||
return resp
|
||||
|
||||
async def _get_folder_id_by_path(self, path: str) -> str:
|
||||
"""
|
||||
通过路径获取文件夹 ID。
|
||||
:param path: 路径。
|
||||
:return: 文件夹 ID。
|
||||
"""
|
||||
if not path or path == "/":
|
||||
return self.root_folder_id
|
||||
|
||||
parts = [p for p in path.strip("/").split("/") if p]
|
||||
current_id = self.root_folder_id
|
||||
|
||||
for part in parts:
|
||||
query = f"name='{part}' and '{current_id}' in parents and mimeType='application/vnd.google-apps.folder' and trashed=false"
|
||||
params = {"q": query, "fields": "files(id, name)"}
|
||||
resp = await self._request("GET", "/files", params=params)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
files = data.get("files", [])
|
||||
if not files:
|
||||
raise FileNotFoundError(f"文件夹不存在: {part}")
|
||||
current_id = files[0]["id"]
|
||||
|
||||
return current_id
|
||||
|
||||
async def _get_file_id_by_path(self, path: str) -> str | None:
|
||||
"""
|
||||
通过路径获取文件 ID。
|
||||
:param path: 文件路径。
|
||||
:return: 文件 ID 或 None。
|
||||
"""
|
||||
if not path or path == "/":
|
||||
return self.root_folder_id
|
||||
|
||||
parts = [p for p in path.strip("/").split("/") if p]
|
||||
parent_id = self.root_folder_id
|
||||
|
||||
for i, part in enumerate(parts):
|
||||
is_last = i == len(parts) - 1
|
||||
mime_filter = "" if is_last else "and mimeType='application/vnd.google-apps.folder'"
|
||||
query = f"name='{part}' and '{parent_id}' in parents {mime_filter} and trashed=false"
|
||||
params = {"q": query, "fields": "files(id, name)"}
|
||||
resp = await self._request("GET", "/files", params=params)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
files = data.get("files", [])
|
||||
if not files:
|
||||
return None
|
||||
parent_id = files[0]["id"]
|
||||
|
||||
return parent_id
|
||||
|
||||
def _format_item(self, item: Dict) -> Dict:
|
||||
"""
|
||||
将 Google Drive API 返回的 item 格式化为统一的格式。
|
||||
:param item: Google Drive API 返回的 item 字典。
|
||||
:return: 格式化后的字典。
|
||||
"""
|
||||
is_dir = item["mimeType"] == "application/vnd.google-apps.folder"
|
||||
mtime_str = item.get("modifiedTime", item.get("createdTime", ""))
|
||||
try:
|
||||
mtime = int(datetime.fromisoformat(mtime_str.replace("Z", "+00:00")).timestamp())
|
||||
except:
|
||||
mtime = 0
|
||||
|
||||
return {
|
||||
"name": item["name"],
|
||||
"is_dir": is_dir,
|
||||
"size": 0 if is_dir else int(item.get("size", 0)),
|
||||
"mtime": mtime,
|
||||
"type": "dir" if is_dir else "file",
|
||||
}
|
||||
|
||||
async def list_dir(self, root: str, rel: str, page_num: int = 1, page_size: int = 50, sort_by: str = "name", sort_order: str = "asc") -> Tuple[List[Dict], int]:
|
||||
"""
|
||||
列出目录内容。
|
||||
:param root: 根路径。
|
||||
:param rel: 相对路径。
|
||||
:param page_num: 页码。
|
||||
:param page_size: 每页大小。
|
||||
:param sort_by: 排序字段
|
||||
:param sort_order: 排序顺序
|
||||
:return: 文件/目录列表和总数。
|
||||
"""
|
||||
try:
|
||||
folder_id = await self._get_folder_id_by_path(rel)
|
||||
except FileNotFoundError:
|
||||
return [], 0
|
||||
|
||||
query = f"'{folder_id}' in parents and trashed=false"
|
||||
params = {
|
||||
"q": query,
|
||||
"fields": "files(id, name, mimeType, size, modifiedTime, createdTime)",
|
||||
"pageSize": 1000,
|
||||
}
|
||||
|
||||
all_items = []
|
||||
page_token = None
|
||||
|
||||
while True:
|
||||
if page_token:
|
||||
params["pageToken"] = page_token
|
||||
|
||||
resp = await self._request("GET", "/files", params=params)
|
||||
if resp.status_code == 404:
|
||||
return [], 0
|
||||
resp.raise_for_status()
|
||||
|
||||
data = resp.json()
|
||||
all_items.extend(data.get("files", []))
|
||||
page_token = data.get("nextPageToken")
|
||||
|
||||
if not page_token:
|
||||
break
|
||||
|
||||
formatted_items = [self._format_item(item) for item in all_items]
|
||||
|
||||
# 排序
|
||||
reverse = sort_order.lower() == "desc"
|
||||
def get_sort_key(item):
|
||||
key = (not item["is_dir"],)
|
||||
sort_field = sort_by.lower()
|
||||
if sort_field == "name":
|
||||
key += (item["name"].lower(),)
|
||||
elif sort_field == "size":
|
||||
key += (item["size"],)
|
||||
elif sort_field == "mtime":
|
||||
key += (item["mtime"],)
|
||||
else:
|
||||
key += (item["name"].lower(),)
|
||||
return key
|
||||
formatted_items.sort(key=get_sort_key, reverse=reverse)
|
||||
|
||||
total_count = len(formatted_items)
|
||||
start_idx = (page_num - 1) * page_size
|
||||
end_idx = start_idx + page_size
|
||||
|
||||
return formatted_items[start_idx:end_idx], total_count
|
||||
|
||||
async def read_file(self, root: str, rel: str) -> bytes:
|
||||
"""
|
||||
读取文件内容。
|
||||
:param root: 根路径。
|
||||
:param rel: 相对路径。
|
||||
:return: 文件内容的字节流。
|
||||
"""
|
||||
file_id = await self._get_file_id_by_path(rel)
|
||||
if not file_id:
|
||||
raise FileNotFoundError(rel)
|
||||
|
||||
resp = await self._request("GET", f"/files/{file_id}", params={"alt": "media"})
|
||||
if resp.status_code == 404:
|
||||
raise FileNotFoundError(rel)
|
||||
resp.raise_for_status()
|
||||
return resp.content
|
||||
|
||||
async def write_file(self, root: str, rel: str, data: bytes):
|
||||
"""
|
||||
写入文件。
|
||||
:param root: 根路径。
|
||||
:param rel: 相对路径。
|
||||
:param data: 文件内容的字节流。
|
||||
"""
|
||||
parent_path = "/".join(rel.strip("/").split("/")[:-1])
|
||||
file_name = rel.strip("/").split("/")[-1]
|
||||
parent_id = await self._get_folder_id_by_path(parent_path)
|
||||
|
||||
# 检查文件是否已存在
|
||||
existing_id = await self._get_file_id_by_path(rel)
|
||||
|
||||
if existing_id:
|
||||
# 更新现有文件
|
||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||
token = await self._get_access_token()
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
url = f"https://www.googleapis.com/upload/drive/v3/files/{existing_id}?uploadType=media"
|
||||
resp = await client.patch(url, headers=headers, content=data)
|
||||
resp.raise_for_status()
|
||||
else:
|
||||
# 创建新文件
|
||||
metadata = {
|
||||
"name": file_name,
|
||||
"parents": [parent_id]
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||
token = await self._get_access_token()
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
# 使用 multipart 上传
|
||||
import json
|
||||
boundary = "===============boundary==============="
|
||||
headers["Content-Type"] = f"multipart/related; boundary={boundary}"
|
||||
|
||||
body = (
|
||||
f"--{boundary}\r\n"
|
||||
f"Content-Type: application/json; charset=UTF-8\r\n\r\n"
|
||||
f"{json.dumps(metadata)}\r\n"
|
||||
f"--{boundary}\r\n"
|
||||
f"Content-Type: application/octet-stream\r\n\r\n"
|
||||
).encode() + data + f"\r\n--{boundary}--".encode()
|
||||
|
||||
url = "https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart"
|
||||
resp = await client.post(url, headers=headers, content=body)
|
||||
resp.raise_for_status()
|
||||
|
||||
async def write_file_stream(self, root: str, rel: str, data_iter: AsyncIterator[bytes]):
|
||||
"""
|
||||
以流式方式写入文件。
|
||||
:param root: 根路径。
|
||||
:param rel: 相对路径。
|
||||
:param data_iter: 文件内容的异步迭代器。
|
||||
:return: 文件大小。
|
||||
"""
|
||||
# 先收集所有数据
|
||||
chunks = []
|
||||
total_size = 0
|
||||
async for chunk in data_iter:
|
||||
chunks.append(chunk)
|
||||
total_size += len(chunk)
|
||||
|
||||
data = b"".join(chunks)
|
||||
await self.write_file(root, rel, data)
|
||||
return total_size
|
||||
|
||||
async def mkdir(self, root: str, rel: str):
|
||||
"""
|
||||
创建目录。
|
||||
:param root: 根路径。
|
||||
:param rel: 相对路径。
|
||||
"""
|
||||
parent_path = "/".join(rel.strip("/").split("/")[:-1])
|
||||
folder_name = rel.strip("/").split("/")[-1]
|
||||
parent_id = await self._get_folder_id_by_path(parent_path)
|
||||
|
||||
metadata = {
|
||||
"name": folder_name,
|
||||
"mimeType": "application/vnd.google-apps.folder",
|
||||
"parents": [parent_id]
|
||||
}
|
||||
|
||||
resp = await self._request("POST", "/files", json=metadata)
|
||||
resp.raise_for_status()
|
||||
|
||||
async def delete(self, root: str, rel: str):
|
||||
"""
|
||||
删除文件或目录。
|
||||
:param root: 根路径。
|
||||
:param rel: 相对路径。
|
||||
"""
|
||||
file_id = await self._get_file_id_by_path(rel)
|
||||
if not file_id:
|
||||
return
|
||||
|
||||
resp = await self._request("DELETE", f"/files/{file_id}")
|
||||
if resp.status_code not in (204, 404):
|
||||
resp.raise_for_status()
|
||||
|
||||
async def move(self, root: str, src_rel: str, dst_rel: str):
|
||||
"""
|
||||
移动或重命名文件/目录。
|
||||
:param root: 根路径。
|
||||
:param src_rel: 源相对路径。
|
||||
:param dst_rel: 目标相对路径。
|
||||
"""
|
||||
file_id = await self._get_file_id_by_path(src_rel)
|
||||
if not file_id:
|
||||
raise FileNotFoundError(src_rel)
|
||||
|
||||
# 获取当前父文件夹
|
||||
resp = await self._request("GET", f"/files/{file_id}", params={"fields": "parents"})
|
||||
resp.raise_for_status()
|
||||
current_parents = resp.json().get("parents", [])
|
||||
|
||||
# 获取目标父文件夹和新名称
|
||||
dst_parent_path = "/".join(dst_rel.strip("/").split("/")[:-1])
|
||||
dst_name = dst_rel.strip("/").split("/")[-1]
|
||||
dst_parent_id = await self._get_folder_id_by_path(dst_parent_path)
|
||||
|
||||
# 更新文件
|
||||
params = {
|
||||
"addParents": dst_parent_id,
|
||||
"removeParents": ",".join(current_parents) if current_parents else None,
|
||||
}
|
||||
metadata = {"name": dst_name}
|
||||
|
||||
resp = await self._request("PATCH", f"/files/{file_id}", params=params, json=metadata)
|
||||
resp.raise_for_status()
|
||||
|
||||
async def rename(self, root: str, src_rel: str, dst_rel: str):
|
||||
"""
|
||||
重命名文件或目录。
|
||||
"""
|
||||
await self.move(root, src_rel, dst_rel)
|
||||
|
||||
async def copy(self, root: str, src_rel: str, dst_rel: str, overwrite: bool = False):
|
||||
"""
|
||||
复制文件或目录。
|
||||
:param root: 根路径。
|
||||
:param src_rel: 源相对路径。
|
||||
:param dst_rel: 目标相对路径。
|
||||
:param overwrite: 是否覆盖。
|
||||
"""
|
||||
file_id = await self._get_file_id_by_path(src_rel)
|
||||
if not file_id:
|
||||
raise FileNotFoundError(src_rel)
|
||||
|
||||
dst_parent_path = "/".join(dst_rel.strip("/").split("/")[:-1])
|
||||
dst_name = dst_rel.strip("/").split("/")[-1]
|
||||
dst_parent_id = await self._get_folder_id_by_path(dst_parent_path)
|
||||
|
||||
metadata = {
|
||||
"name": dst_name,
|
||||
"parents": [dst_parent_id]
|
||||
}
|
||||
|
||||
resp = await self._request("POST", f"/files/{file_id}/copy", json=metadata)
|
||||
resp.raise_for_status()
|
||||
|
||||
async def stream_file(self, root: str, rel: str, range_header: str | None):
|
||||
"""
|
||||
流式传输文件(支持范围请求)。
|
||||
:param root: 根路径。
|
||||
:param rel: 相对路径。
|
||||
:param range_header: HTTP Range 头。
|
||||
:return: FastAPI StreamingResponse 对象。
|
||||
"""
|
||||
file_id = await self._get_file_id_by_path(rel)
|
||||
if not file_id:
|
||||
raise FileNotFoundError(rel)
|
||||
|
||||
# 获取文件元数据
|
||||
resp = await self._request("GET", f"/files/{file_id}", params={"fields": "name, size, mimeType"})
|
||||
if resp.status_code == 404:
|
||||
raise FileNotFoundError(rel)
|
||||
resp.raise_for_status()
|
||||
item_data = resp.json()
|
||||
|
||||
file_size = int(item_data.get("size", 0))
|
||||
content_type = item_data.get("mimeType", "application/octet-stream")
|
||||
|
||||
start = 0
|
||||
end = file_size - 1
|
||||
status = 200
|
||||
headers = {
|
||||
"Accept-Ranges": "bytes",
|
||||
"Content-Type": content_type,
|
||||
"Content-Disposition": f"inline; filename=\"{item_data.get('name')}\""
|
||||
}
|
||||
|
||||
if range_header and range_header.startswith("bytes="):
|
||||
try:
|
||||
part = range_header.removeprefix("bytes=")
|
||||
s, e = part.split("-", 1)
|
||||
if s.strip():
|
||||
start = int(s)
|
||||
if e.strip():
|
||||
end = int(e)
|
||||
if start >= file_size:
|
||||
raise HTTPException(416, "Requested Range Not Satisfiable")
|
||||
if end >= file_size:
|
||||
end = file_size - 1
|
||||
status = 206
|
||||
except ValueError:
|
||||
raise HTTPException(400, "Invalid Range header")
|
||||
|
||||
headers["Content-Range"] = f"bytes {start}-{end}/{file_size}"
|
||||
headers["Content-Length"] = str(end - start + 1)
|
||||
else:
|
||||
headers["Content-Length"] = str(file_size)
|
||||
|
||||
async def file_iterator():
|
||||
nonlocal start, end
|
||||
token = await self._get_access_token()
|
||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||
req_headers = {
|
||||
'Authorization': f'Bearer {token}',
|
||||
'Range': f'bytes={start}-{end}'
|
||||
}
|
||||
url = f"{GOOGLE_DRIVE_API_URL}/files/{file_id}?alt=media"
|
||||
async with client.stream("GET", url, headers=req_headers) as stream_resp:
|
||||
stream_resp.raise_for_status()
|
||||
async for chunk in stream_resp.aiter_bytes():
|
||||
yield chunk
|
||||
|
||||
return StreamingResponse(file_iterator(), status_code=status, headers=headers, media_type=content_type)
|
||||
|
||||
async def stat_file(self, root: str, rel: str):
|
||||
"""
|
||||
获取文件或目录的元数据。
|
||||
:param root: 根路径。
|
||||
:param rel: 相对路径。
|
||||
:return: 格式化后的文件/目录信息。
|
||||
"""
|
||||
file_id = await self._get_file_id_by_path(rel)
|
||||
if not file_id:
|
||||
raise FileNotFoundError(rel)
|
||||
|
||||
resp = await self._request("GET", f"/files/{file_id}", params={"fields": "id, name, mimeType, size, modifiedTime, createdTime"})
|
||||
if resp.status_code == 404:
|
||||
raise FileNotFoundError(rel)
|
||||
resp.raise_for_status()
|
||||
return self._format_item(resp.json())
|
||||
|
||||
async def get_direct_download_response(self, root: str, rel: str):
|
||||
"""
|
||||
获取直接下载响应 (307 重定向)。
|
||||
:param root: 根路径。
|
||||
:param rel: 相对路径。
|
||||
:return: 307 重定向响应或 None。
|
||||
"""
|
||||
if not self.enable_redirect_307:
|
||||
return None
|
||||
|
||||
file_id = await self._get_file_id_by_path(rel)
|
||||
if not file_id:
|
||||
raise FileNotFoundError(rel)
|
||||
|
||||
# 获取文件的下载链接
|
||||
resp = await self._request("GET", f"/files/{file_id}", params={"fields": "webContentLink"})
|
||||
if resp.status_code == 404:
|
||||
raise FileNotFoundError(rel)
|
||||
resp.raise_for_status()
|
||||
|
||||
item_data = resp.json()
|
||||
download_url = item_data.get("webContentLink")
|
||||
if not download_url:
|
||||
return None
|
||||
|
||||
return Response(status_code=307, headers={"Location": download_url})
|
||||
|
||||
async def get_thumbnail(self, root: str, rel: str, size: str = "medium"):
|
||||
"""
|
||||
获取文件的缩略图。
|
||||
:param root: 根路径。
|
||||
:param rel: 相对路径。
|
||||
:param size: 缩略图大小 (暂未使用,Google Drive 自动决定)。
|
||||
:return: 缩略图内容的字节流,或在不支持时返回 None。
|
||||
"""
|
||||
file_id = await self._get_file_id_by_path(rel)
|
||||
if not file_id:
|
||||
return None
|
||||
|
||||
try:
|
||||
resp = await self._request("GET", f"/files/{file_id}", params={"fields": "thumbnailLink"})
|
||||
if resp.status_code == 200:
|
||||
item_data = resp.json()
|
||||
thumbnail_link = item_data.get("thumbnailLink")
|
||||
if thumbnail_link:
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
thumb_resp = await client.get(thumbnail_link)
|
||||
thumb_resp.raise_for_status()
|
||||
return thumb_resp.content
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
ADAPTER_TYPE = "googledrive"
|
||||
|
||||
CONFIG_SCHEMA = [
|
||||
{"key": "client_id", "label": "Client ID", "type": "string", "required": True},
|
||||
{"key": "client_secret", "label": "Client Secret",
|
||||
"type": "password", "required": True},
|
||||
{"key": "refresh_token", "label": "Refresh Token", "type": "password",
|
||||
"required": True, "help_text": "可以通过 Google OAuth 2.0 Playground 获取"},
|
||||
{"key": "root_folder_id", "label": "根文件夹 ID (Root Folder ID)", "type": "string",
|
||||
"required": False, "placeholder": "默认为根目录 (root)", "default": "root"},
|
||||
{"key": "enable_direct_download_307", "label": "Enable 307 redirect download", "type": "boolean", "default": False},
|
||||
]
|
||||
|
||||
|
||||
def ADAPTER_FACTORY(rec): return GoogleDriveAdapter(rec)
|
||||
@@ -445,7 +445,7 @@ class OneDriveAdapter:
|
||||
return self._format_item(resp.json())
|
||||
|
||||
|
||||
ADAPTER_TYPE = "OneDrive"
|
||||
ADAPTER_TYPE = "onedrive"
|
||||
|
||||
CONFIG_SCHEMA = [
|
||||
{"key": "client_id", "label": "Client ID", "type": "string", "required": True},
|
||||
|
||||
@@ -718,7 +718,7 @@ class QuarkAdapter:
|
||||
return it["fid"]
|
||||
|
||||
|
||||
ADAPTER_TYPE = "Quark"
|
||||
ADAPTER_TYPE = "quark"
|
||||
|
||||
CONFIG_SCHEMA = [
|
||||
{"key": "cookie", "label": "Cookie", "type": "password", "required": True, "placeholder": "从 pan.quark.cn 复制"},
|
||||
|
||||
@@ -12,6 +12,13 @@ TYPE_MAP: Dict[str, AdapterFactory] = {}
|
||||
CONFIG_SCHEMAS: Dict[str, list] = {}
|
||||
|
||||
|
||||
def normalize_adapter_type(value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
normalized = str(value).strip().lower()
|
||||
return normalized or None
|
||||
|
||||
|
||||
def discover_adapters():
|
||||
"""扫描 services.adapters 包, 自动注册适配器类型、工厂与配置 schema。"""
|
||||
from .. import adapters as adapters_pkg
|
||||
@@ -25,7 +32,7 @@ def discover_adapters():
|
||||
module = import_module(full_name)
|
||||
except Exception:
|
||||
continue
|
||||
adapter_type = getattr(module, "ADAPTER_TYPE", None)
|
||||
adapter_type = normalize_adapter_type(getattr(module, "ADAPTER_TYPE", None))
|
||||
schema = getattr(module, "CONFIG_SCHEMA", None)
|
||||
factory = getattr(module, "ADAPTER_FACTORY", None)
|
||||
|
||||
@@ -64,7 +71,16 @@ class RuntimeRegistry:
|
||||
self._instances.clear()
|
||||
adapters = await StorageAdapter.filter(enabled=True)
|
||||
for rec in adapters:
|
||||
factory = TYPE_MAP.get(rec.type)
|
||||
normalized_type = normalize_adapter_type(rec.type)
|
||||
if not normalized_type:
|
||||
continue
|
||||
if normalized_type != rec.type:
|
||||
rec.type = normalized_type
|
||||
try:
|
||||
await rec.save(update_fields=["type"])
|
||||
except Exception:
|
||||
continue
|
||||
factory = TYPE_MAP.get(normalized_type)
|
||||
if not factory:
|
||||
continue
|
||||
try:
|
||||
@@ -89,10 +105,21 @@ class RuntimeRegistry:
|
||||
self.remove(rec.id)
|
||||
return
|
||||
|
||||
factory = TYPE_MAP.get(rec.type)
|
||||
normalized_type = normalize_adapter_type(rec.type)
|
||||
if not normalized_type:
|
||||
self.remove(rec.id)
|
||||
return
|
||||
if normalized_type != rec.type:
|
||||
rec.type = normalized_type
|
||||
try:
|
||||
await rec.save(update_fields=["type"])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
factory = TYPE_MAP.get(normalized_type)
|
||||
if not factory:
|
||||
discover_adapters()
|
||||
factory = TYPE_MAP.get(rec.type)
|
||||
factory = TYPE_MAP.get(normalized_type)
|
||||
if not factory:
|
||||
return
|
||||
|
||||
|
||||
@@ -359,7 +359,7 @@ class S3Adapter:
|
||||
return StreamingResponse(iterator(), status_code=status, headers=headers, media_type=content_type)
|
||||
|
||||
|
||||
ADAPTER_TYPE = "S3"
|
||||
ADAPTER_TYPE = "s3"
|
||||
|
||||
CONFIG_SCHEMA = [
|
||||
{"key": "bucket_name", "label": "Bucket 名称",
|
||||
|
||||
@@ -8,7 +8,7 @@ from telethon.sessions import StringSession
|
||||
import socks
|
||||
|
||||
# 适配器类型标识
|
||||
ADAPTER_TYPE = "Telegram"
|
||||
ADAPTER_TYPE = "telegram"
|
||||
|
||||
# 适配器配置项定义
|
||||
CONFIG_SCHEMA = [
|
||||
|
||||
@@ -455,8 +455,16 @@ class WebDAVAdapter:
|
||||
info["type"] = "dir" if is_dir else "file"
|
||||
if size_el is not None and size_el.text and size_el.text.isdigit():
|
||||
info["size"] = int(size_el.text)
|
||||
elif info["size"] is None:
|
||||
info["size"] = 0
|
||||
if lm_el is not None and lm_el.text:
|
||||
info["mtime"] = lm_el.text
|
||||
from email.utils import parsedate_to_datetime
|
||||
try:
|
||||
info["mtime"] = int(parsedate_to_datetime(lm_el.text).timestamp())
|
||||
except Exception:
|
||||
info["mtime"] = 0
|
||||
elif info["mtime"] is None:
|
||||
info["mtime"] = 0
|
||||
# exif信息
|
||||
exif = None
|
||||
if not info["is_dir"]:
|
||||
|
||||
160
services/auth.py
160
services/auth.py
@@ -1,5 +1,8 @@
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Annotated
|
||||
import secrets
|
||||
|
||||
import jwt
|
||||
from fastapi import Depends, HTTPException, status
|
||||
@@ -10,9 +13,78 @@ from pydantic import BaseModel
|
||||
|
||||
from models.database import UserAccount
|
||||
from services.config import ConfigCenter
|
||||
from services.logging import LogService
|
||||
|
||||
ALGORITHM = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 365
|
||||
PASSWORD_RESET_TOKEN_EXPIRE_MINUTES = 10
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PasswordResetEntry:
|
||||
user_id: int
|
||||
email: str
|
||||
username: str
|
||||
expires_at: datetime
|
||||
used: bool = False
|
||||
|
||||
|
||||
class PasswordResetStore:
|
||||
_tokens: dict[str, PasswordResetEntry] = {}
|
||||
_lock = asyncio.Lock()
|
||||
|
||||
@classmethod
|
||||
def _cleanup(cls):
|
||||
now = _now()
|
||||
for token, record in list(cls._tokens.items()):
|
||||
if record.used or record.expires_at < now:
|
||||
cls._tokens.pop(token, None)
|
||||
|
||||
@classmethod
|
||||
async def create(cls, user: UserAccount) -> str:
|
||||
async with cls._lock:
|
||||
cls._cleanup()
|
||||
for key, record in list(cls._tokens.items()):
|
||||
if record.user_id == user.id:
|
||||
cls._tokens.pop(key, None)
|
||||
token = secrets.token_urlsafe(32)
|
||||
expires_at = _now() + timedelta(minutes=PASSWORD_RESET_TOKEN_EXPIRE_MINUTES)
|
||||
cls._tokens[token] = PasswordResetEntry(
|
||||
user_id=user.id,
|
||||
email=user.email or "",
|
||||
username=user.username,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
return token
|
||||
|
||||
@classmethod
|
||||
async def get(cls, token: str) -> PasswordResetEntry | None:
|
||||
async with cls._lock:
|
||||
cls._cleanup()
|
||||
record = cls._tokens.get(token)
|
||||
if not record or record.used:
|
||||
return None
|
||||
return record
|
||||
|
||||
@classmethod
|
||||
async def mark_used(cls, token: str) -> None:
|
||||
async with cls._lock:
|
||||
record = cls._tokens.get(token)
|
||||
if record:
|
||||
record.used = True
|
||||
cls._cleanup()
|
||||
|
||||
@classmethod
|
||||
async def invalidate_user(cls, user_id: int, except_token: str | None = None) -> None:
|
||||
async with cls._lock:
|
||||
for key, record in list(cls._tokens.items()):
|
||||
if record.user_id == user_id and key != except_token:
|
||||
cls._tokens.pop(key, None)
|
||||
cls._cleanup()
|
||||
|
||||
|
||||
async def get_secret_key():
|
||||
@@ -132,6 +204,94 @@ async def create_access_token(data: dict, expires_delta: timedelta | None = None
|
||||
return encoded_jwt
|
||||
|
||||
|
||||
def _normalize_email(email: str | None) -> str:
|
||||
return (email or "").strip().lower()
|
||||
|
||||
|
||||
async def _send_password_reset_email(user: UserAccount, token: str) -> None:
|
||||
from services.email import EmailService
|
||||
|
||||
app_domain = await ConfigCenter.get("APP_DOMAIN", None)
|
||||
base_url = (app_domain or "http://localhost:5173").rstrip("/")
|
||||
reset_link = f"{base_url}/reset-password?token={token}"
|
||||
await EmailService.enqueue_email(
|
||||
recipients=[user.email],
|
||||
subject="Foxel 密码重置",
|
||||
template="password_reset",
|
||||
context={
|
||||
"username": user.username,
|
||||
"reset_link": reset_link,
|
||||
"expire_minutes": PASSWORD_RESET_TOKEN_EXPIRE_MINUTES,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def request_password_reset(email: str) -> bool:
|
||||
normalized = _normalize_email(email)
|
||||
if not normalized:
|
||||
return False
|
||||
user = await UserAccount.get_or_none(email=normalized)
|
||||
if not user or not user.email:
|
||||
return False
|
||||
|
||||
token = await PasswordResetStore.create(user)
|
||||
try:
|
||||
await _send_password_reset_email(user, token)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
await PasswordResetStore.mark_used(token)
|
||||
await PasswordResetStore.invalidate_user(user.id)
|
||||
await LogService.error(
|
||||
"auth",
|
||||
f"Failed to enqueue password reset email: {exc}",
|
||||
details={"user_id": user.id},
|
||||
user_id=user.id,
|
||||
)
|
||||
raise HTTPException(status_code=500, detail="邮件发送失败") from exc
|
||||
await LogService.action(
|
||||
"auth",
|
||||
"Password reset requested",
|
||||
details={"user_id": user.id},
|
||||
user_id=user.id,
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
async def verify_password_reset_token(token: str) -> UserAccount:
|
||||
record = await PasswordResetStore.get(token)
|
||||
if not record:
|
||||
raise HTTPException(status_code=400, detail="重置链接无效")
|
||||
user = await UserAccount.get_or_none(id=record.user_id)
|
||||
if not user:
|
||||
raise HTTPException(status_code=400, detail="重置链接无效")
|
||||
if record.expires_at < _now():
|
||||
await PasswordResetStore.mark_used(token)
|
||||
raise HTTPException(status_code=400, detail="重置链接已过期")
|
||||
return user
|
||||
|
||||
|
||||
async def reset_password_with_token(token: str, new_password: str) -> None:
|
||||
record = await PasswordResetStore.get(token)
|
||||
if not record:
|
||||
raise HTTPException(status_code=400, detail="重置链接无效")
|
||||
if record.expires_at < _now():
|
||||
await PasswordResetStore.mark_used(token)
|
||||
raise HTTPException(status_code=400, detail="重置链接已过期")
|
||||
|
||||
user = await UserAccount.get_or_none(id=record.user_id)
|
||||
if not user:
|
||||
raise HTTPException(status_code=400, detail="重置链接无效")
|
||||
user.hashed_password = get_password_hash(new_password)
|
||||
await user.save(update_fields=["hashed_password"])
|
||||
await PasswordResetStore.mark_used(token)
|
||||
await PasswordResetStore.invalidate_user(user.id)
|
||||
await LogService.action(
|
||||
"auth",
|
||||
"Password reset via email",
|
||||
details={"user_id": user.id},
|
||||
user_id=user.id,
|
||||
)
|
||||
|
||||
|
||||
async def get_current_user(token: Annotated[str, Depends(oauth2_scheme)]):
|
||||
credentials_exception = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
|
||||
@@ -4,7 +4,7 @@ from typing import Any, Optional, Dict
|
||||
from dotenv import load_dotenv
|
||||
from models.database import Configuration
|
||||
load_dotenv(dotenv_path=".env")
|
||||
VERSION = "v1.3.3"
|
||||
VERSION = "v1.3.8"
|
||||
|
||||
class ConfigCenter:
|
||||
_cache: Dict[str, Any] = {}
|
||||
|
||||
201
services/email.py
Normal file
201
services/email.py
Normal file
@@ -0,0 +1,201 @@
|
||||
import asyncio
|
||||
import json
|
||||
import re
|
||||
import smtplib
|
||||
from email.message import EmailMessage
|
||||
from email.utils import formataddr
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from string import Template
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from pydantic import BaseModel, EmailStr, Field, ValidationError
|
||||
|
||||
from services.config import ConfigCenter
|
||||
from services.logging import LogService
|
||||
|
||||
|
||||
class EmailSecurity(str, Enum):
|
||||
NONE = "none"
|
||||
SSL = "ssl"
|
||||
STARTTLS = "starttls"
|
||||
|
||||
|
||||
class EmailConfig(BaseModel):
|
||||
host: str
|
||||
port: int = Field(..., gt=0)
|
||||
username: Optional[str] = None
|
||||
password: Optional[str] = None
|
||||
sender_email: EmailStr
|
||||
sender_name: Optional[str] = None
|
||||
security: EmailSecurity = EmailSecurity.NONE
|
||||
timeout: float = Field(default=30.0, gt=0.0)
|
||||
|
||||
|
||||
class EmailSendPayload(BaseModel):
|
||||
recipients: List[EmailStr] = Field(..., min_length=1)
|
||||
subject: str = Field(..., min_length=1)
|
||||
template: str = Field(..., min_length=1)
|
||||
context: Dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class EmailTemplateRenderer:
|
||||
ROOT = Path("templates/email")
|
||||
|
||||
@classmethod
|
||||
def _resolve_path(cls, template_name: str) -> Path:
|
||||
if not re.fullmatch(r"[A-Za-z0-9_\-]+", template_name):
|
||||
raise ValueError("Invalid template name")
|
||||
return cls.ROOT / f"{template_name}.html"
|
||||
|
||||
@classmethod
|
||||
async def list_templates(cls) -> list[str]:
|
||||
cls.ROOT.mkdir(parents=True, exist_ok=True)
|
||||
return sorted(
|
||||
path.stem
|
||||
for path in cls.ROOT.glob("*.html")
|
||||
if path.is_file()
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def load(cls, template_name: str) -> str:
|
||||
path = cls._resolve_path(template_name)
|
||||
if not path.is_file():
|
||||
raise FileNotFoundError(f"Email template '{template_name}' not found")
|
||||
return await asyncio.to_thread(path.read_text, encoding="utf-8")
|
||||
|
||||
@classmethod
|
||||
async def save(cls, template_name: str, content: str) -> None:
|
||||
path = cls._resolve_path(template_name)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
await asyncio.to_thread(path.write_text, content, encoding="utf-8")
|
||||
|
||||
@classmethod
|
||||
async def render(cls, template_name: str, context: Dict[str, Any]) -> str:
|
||||
raw = await cls.load(template_name)
|
||||
context = {k: str(v) for k, v in (context or {}).items()}
|
||||
return Template(raw).safe_substitute(context)
|
||||
|
||||
|
||||
class EmailService:
|
||||
CONFIG_KEY = "EMAIL_CONFIG"
|
||||
|
||||
@classmethod
|
||||
async def _load_config(cls) -> EmailConfig:
|
||||
raw_config = await ConfigCenter.get(cls.CONFIG_KEY)
|
||||
if raw_config is None:
|
||||
raise ValueError("Email configuration not found")
|
||||
|
||||
if isinstance(raw_config, str):
|
||||
raw_config = raw_config.strip()
|
||||
data: Any = json.loads(raw_config) if raw_config else {}
|
||||
elif isinstance(raw_config, dict):
|
||||
data = raw_config
|
||||
else:
|
||||
raise ValueError("Invalid email configuration format")
|
||||
|
||||
try:
|
||||
return EmailConfig(**data)
|
||||
except ValidationError as exc:
|
||||
raise ValueError(f"Invalid email configuration: {exc}") from exc
|
||||
|
||||
@staticmethod
|
||||
def _html_to_text(html: str) -> str:
|
||||
stripped = re.sub(r"<[^>]+>", " ", html)
|
||||
return " ".join(stripped.split())
|
||||
|
||||
@classmethod
|
||||
async def _deliver(cls, config: EmailConfig, payload: EmailSendPayload, html_body: str):
|
||||
message = EmailMessage()
|
||||
message["Subject"] = payload.subject
|
||||
message["From"] = formataddr((config.sender_name or str(config.sender_email), str(config.sender_email)))
|
||||
message["To"] = ", ".join([str(addr) for addr in payload.recipients])
|
||||
|
||||
plain_body = cls._html_to_text(html_body)
|
||||
message.set_content(plain_body or html_body)
|
||||
message.add_alternative(html_body, subtype="html")
|
||||
|
||||
await asyncio.to_thread(cls._deliver_sync, config, message)
|
||||
|
||||
@staticmethod
|
||||
def _deliver_sync(config: EmailConfig, message: EmailMessage):
|
||||
if config.security == EmailSecurity.SSL:
|
||||
smtp: smtplib.SMTP = smtplib.SMTP_SSL(config.host, config.port, timeout=config.timeout)
|
||||
else:
|
||||
smtp = smtplib.SMTP(config.host, config.port, timeout=config.timeout)
|
||||
|
||||
try:
|
||||
if config.security == EmailSecurity.STARTTLS:
|
||||
smtp.starttls()
|
||||
if config.username and config.password:
|
||||
smtp.login(config.username, config.password)
|
||||
smtp.send_message(message)
|
||||
finally:
|
||||
try:
|
||||
smtp.quit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
async def enqueue_email(
|
||||
cls,
|
||||
recipients: List[str],
|
||||
subject: str,
|
||||
template: str,
|
||||
context: Optional[Dict[str, Any]] = None,
|
||||
):
|
||||
from services.task_queue import TaskProgress, task_queue_service
|
||||
|
||||
payload = EmailSendPayload(
|
||||
recipients=recipients,
|
||||
subject=subject,
|
||||
template=template,
|
||||
context=context or {},
|
||||
)
|
||||
|
||||
task = await task_queue_service.add_task(
|
||||
"send_email",
|
||||
payload.model_dump(mode="json"),
|
||||
)
|
||||
|
||||
await task_queue_service.update_progress(
|
||||
task.id,
|
||||
TaskProgress(stage="queued", percent=0.0, detail="Waiting to send"),
|
||||
)
|
||||
await LogService.action(
|
||||
"email_service",
|
||||
"Email task enqueued",
|
||||
details={"task_id": task.id, "subject": subject, "template": template},
|
||||
)
|
||||
return task
|
||||
|
||||
@classmethod
|
||||
async def send_from_task(cls, task_id: str, data: Dict[str, Any]):
|
||||
from services.task_queue import TaskProgress, task_queue_service
|
||||
|
||||
payload = EmailSendPayload(**data)
|
||||
|
||||
await task_queue_service.update_progress(
|
||||
task_id,
|
||||
TaskProgress(stage="preparing", percent=10.0, detail="Rendering template"),
|
||||
)
|
||||
|
||||
config = await cls._load_config()
|
||||
html_body = await EmailTemplateRenderer.render(payload.template, payload.context)
|
||||
|
||||
await task_queue_service.update_progress(
|
||||
task_id,
|
||||
TaskProgress(stage="sending", percent=60.0, detail="Sending message"),
|
||||
)
|
||||
|
||||
await cls._deliver(config, payload, html_body)
|
||||
|
||||
await task_queue_service.update_progress(
|
||||
task_id,
|
||||
TaskProgress(stage="completed", percent=100.0, detail="Email sent"),
|
||||
)
|
||||
await LogService.info(
|
||||
"email_service",
|
||||
"Email sent",
|
||||
details={"task_id": task_id, "subject": payload.subject},
|
||||
)
|
||||
@@ -130,6 +130,10 @@ class TaskQueueService:
|
||||
|
||||
result = await run_cross_mount_transfer_task(task)
|
||||
task.result = result
|
||||
elif task.name == "send_email":
|
||||
from services.email import EmailService
|
||||
await EmailService.send_from_task(task.id, task.task_info)
|
||||
task.result = "Email sent"
|
||||
else:
|
||||
raise ValueError(f"Unknown task name: {task.name}")
|
||||
|
||||
|
||||
@@ -307,7 +307,12 @@ async def write_file_stream(path: str, data_iter: AsyncIterator[bytes], overwrit
|
||||
async def make_dir(path: str):
|
||||
adapter_instance, _, root, rel = await resolve_adapter_and_rel(path)
|
||||
if not rel:
|
||||
raise HTTPException(400, detail="Cannot create root")
|
||||
await LogService.info(
|
||||
"virtual_fs",
|
||||
f"Ignored create-root request for {path}",
|
||||
details={"path": path, "reason": "root directory already exists"},
|
||||
)
|
||||
return
|
||||
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})
|
||||
|
||||
102
templates/email/password_reset.html
Normal file
102
templates/email/password_reset.html
Normal file
@@ -0,0 +1,102 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Foxel 密码重置</title>
|
||||
<style>
|
||||
body {
|
||||
background: #f4f7fb;
|
||||
font-family: 'Helvetica Neue', Arial, sans-serif;
|
||||
margin: 0;
|
||||
padding: 32px 0;
|
||||
color: #1f2937;
|
||||
}
|
||||
.wrapper {
|
||||
max-width: 560px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.card {
|
||||
background: #ffffff;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 12px 40px rgba(15, 23, 42, 0.12);
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(99, 102, 241, 0.12);
|
||||
}
|
||||
.header {
|
||||
background: linear-gradient(120deg, #4f46e5, #7c3aed);
|
||||
padding: 32px;
|
||||
color: #ffffff;
|
||||
}
|
||||
.header h1 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
.content {
|
||||
padding: 32px;
|
||||
}
|
||||
.content p {
|
||||
margin: 16px 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.cta {
|
||||
display: block;
|
||||
margin: 32px 0;
|
||||
text-align: center;
|
||||
}
|
||||
.cta a {
|
||||
display: inline-block;
|
||||
background: linear-gradient(120deg, #6366f1, #8b5cf6);
|
||||
color: #ffffff;
|
||||
text-decoration: none;
|
||||
padding: 14px 32px;
|
||||
border-radius: 999px;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 8px 24px rgba(79, 70, 229, 0.32);
|
||||
}
|
||||
.info-box {
|
||||
background: #f5f3ff;
|
||||
border: 1px solid rgba(107, 114, 128, 0.1);
|
||||
border-radius: 12px;
|
||||
padding: 18px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
.footer {
|
||||
padding: 24px 32px;
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
line-height: 1.6;
|
||||
background: #fafafa;
|
||||
border-top: 1px solid rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
.footer a {
|
||||
color: #6366f1;
|
||||
text-decoration: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrapper">
|
||||
<div class="card">
|
||||
<div class="header">
|
||||
<h1>重置你的 Foxel 密码</h1>
|
||||
</div>
|
||||
<div class="content">
|
||||
<p>你好,${username}。</p>
|
||||
<p>我们收到了重置你 Foxel 帐号密码的请求。请点击下方按钮完成密码重置操作:</p>
|
||||
<div class="cta">
|
||||
<a href="${reset_link}" target="_blank" rel="noopener">重置密码</a>
|
||||
</div>
|
||||
<p>如果按钮无法点击,你也可以复制下面的链接到浏览器打开:</p>
|
||||
<div class="info-box">
|
||||
<div style="word-break: break-all;">${reset_link}</div>
|
||||
</div>
|
||||
<p>该链接在 ${expire_minutes} 分钟内有效。若你未发起此请求,请忽略本邮件,你的密码不会发生变化。</p>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<div>此邮件由 Foxel 系统自动发送,请勿直接回复。</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
97
templates/email/test.html
Normal file
97
templates/email/test.html
Normal file
@@ -0,0 +1,97 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Foxel 邮件配置测试</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 32px 0;
|
||||
background: linear-gradient(135deg, #eef2ff, #e0f2fe);
|
||||
font-family: 'Helvetica Neue', Arial, sans-serif;
|
||||
color: #0f172a;
|
||||
}
|
||||
.wrapper {
|
||||
max-width: 560px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.card {
|
||||
background: #ffffff;
|
||||
border-radius: 18px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.18);
|
||||
border: 1px solid rgba(99, 102, 241, 0.08);
|
||||
}
|
||||
.banner {
|
||||
background: linear-gradient(120deg, #1d4ed8, #6366f1);
|
||||
padding: 36px;
|
||||
color: #ffffff;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
.banner h1 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
}
|
||||
.content {
|
||||
padding: 32px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 6px 14px;
|
||||
border-radius: 999px;
|
||||
background: rgba(59, 130, 246, 0.12);
|
||||
color: #1d4ed8;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.cta-box {
|
||||
margin-top: 32px;
|
||||
padding: 20px;
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(135deg, rgba(99, 102, 241, 0.08), rgba(14, 165, 233, 0.08));
|
||||
border: 1px solid rgba(99, 102, 241, 0.12);
|
||||
}
|
||||
.cta-box strong {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-size: 16px;
|
||||
}
|
||||
.footer {
|
||||
padding: 24px 32px;
|
||||
background: #f8fafc;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
border-top: 1px solid rgba(148, 163, 184, 0.18);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrapper">
|
||||
<div class="card">
|
||||
<div class="banner">
|
||||
<h1>Foxel 邮件服务已连通</h1>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="badge">Mail Delivery Test</div>
|
||||
<p>你好,${username}!</p>
|
||||
<p>
|
||||
这是一封来自 <strong>Foxel</strong> 的测试邮件。如果你能够正常阅读到这段内容,说明系统已经成功与配置的邮箱服务建立连接。
|
||||
</p>
|
||||
<div class="cta-box">
|
||||
<strong>接下来可以做什么?</strong>
|
||||
<ul style="margin: 0; padding-left: 18px; line-height: 1.7;">
|
||||
<li>继续完善系统通知、密码重置等业务功能</li>
|
||||
<li>在后台页面中自定义更精美的邮件模板</li>
|
||||
<li>保持发送凭据安全,避免泄露</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer">
|
||||
本邮件由 Foxel 系统自动发送,请勿直接回复。如非本人操作,请忽略此邮件。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
29
uv.lock
generated
29
uv.lock
generated
@@ -373,6 +373,28 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/7c/15ad426257615f9be8caf7f97990cf3dcbb5b8dd7ed7e0db581a1c4759dd/cryptography-46.0.2-cp38-abi3-win_arm64.whl", hash = "sha256:91447f2b17e83c9e0c89f133119d83f94ce6e0fb55dd47da0a959316e6e9cfa1", size = 2918153, upload-time = "2025-10-01T00:28:51.003Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dnspython"
|
||||
version = "2.8.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "email-validator"
|
||||
version = "2.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "dnspython" },
|
||||
{ name = "idna" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastapi"
|
||||
version = "0.116.1"
|
||||
@@ -399,6 +421,7 @@ dependencies = [
|
||||
{ name = "paramiko" },
|
||||
{ name = "passlib", extra = ["bcrypt"] },
|
||||
{ name = "pillow" },
|
||||
{ name = "pydantic", extra = ["email"] },
|
||||
{ name = "pyjwt" },
|
||||
{ name = "pymilvus", extra = ["milvus-lite"] },
|
||||
{ name = "pysocks" },
|
||||
@@ -420,6 +443,7 @@ requires-dist = [
|
||||
{ name = "paramiko", specifier = ">=4.0.0" },
|
||||
{ name = "passlib", extras = ["bcrypt"], specifier = ">=1.7.4" },
|
||||
{ name = "pillow", specifier = ">=11.3.0" },
|
||||
{ name = "pydantic", extras = ["email"], specifier = ">=2.11.7" },
|
||||
{ name = "pyjwt", specifier = ">=2.10.1" },
|
||||
{ name = "pymilvus", extras = ["milvus-lite"], specifier = ">=2.6.2" },
|
||||
{ name = "pysocks", specifier = ">=1.7.1" },
|
||||
@@ -1050,6 +1074,11 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/c0/ec2b1c8712ca690e5d61979dee872603e92b8a32f94cc1b72d53beab008a/pydantic-2.11.7-py3-none-any.whl", hash = "sha256:dde5df002701f6de26248661f6835bbe296a47bf73990135c7d07ce741b9623b", size = 444782, upload-time = "2025-06-14T08:33:14.905Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
email = [
|
||||
{ name = "email-validator" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-core"
|
||||
version = "2.33.2"
|
||||
|
||||
24
web/.gitignore
vendored
24
web/.gitignore
vendored
@@ -1,24 +0,0 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@@ -18,9 +18,14 @@ function AppInner() {
|
||||
const status = await getStatus();
|
||||
setStatus(status);
|
||||
document.title = status.title;
|
||||
const favicon = document.querySelector("link[rel*='icon']") as HTMLLinkElement;
|
||||
let favicon = document.querySelector("link[rel*='icon']") as HTMLLinkElement | null;
|
||||
if (!favicon) {
|
||||
favicon = document.createElement('link');
|
||||
favicon.rel = 'icon';
|
||||
document.head.appendChild(favicon);
|
||||
}
|
||||
if (favicon) {
|
||||
favicon.href = status.logo;
|
||||
favicon.href = status.favicon || status.logo;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to check initialization status:", error);
|
||||
|
||||
@@ -21,7 +21,6 @@ export interface AdapterTypeField {
|
||||
|
||||
export interface AdapterTypeMeta {
|
||||
type: string;
|
||||
name: string;
|
||||
config_schema: AdapterTypeField[];
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,15 @@ export interface UpdateMePayload {
|
||||
new_password?: string;
|
||||
}
|
||||
|
||||
export interface PasswordResetRequestPayload {
|
||||
email: string;
|
||||
}
|
||||
|
||||
export interface PasswordResetConfirmPayload {
|
||||
token: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export const authApi = {
|
||||
register: async (username: string, password: string, email?: string, full_name?: string): Promise<any> => {
|
||||
return request('/auth/register', {
|
||||
@@ -68,4 +77,19 @@ export const authApi = {
|
||||
json: payload,
|
||||
});
|
||||
},
|
||||
requestPasswordReset: async (payload: PasswordResetRequestPayload) => {
|
||||
return await request('/auth/password-reset/request', {
|
||||
method: 'POST',
|
||||
json: payload,
|
||||
});
|
||||
},
|
||||
verifyPasswordResetToken: async (token: string) => {
|
||||
return await request<{ username: string; email: string }>('/auth/password-reset/verify?token=' + encodeURIComponent(token));
|
||||
},
|
||||
confirmPasswordReset: async (payload: PasswordResetConfirmPayload) => {
|
||||
return await request('/auth/password-reset/confirm', {
|
||||
method: 'POST',
|
||||
json: payload,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -19,6 +19,7 @@ export interface SystemStatus {
|
||||
version: string;
|
||||
title: string;
|
||||
logo: string;
|
||||
favicon: string;
|
||||
is_initialized: boolean;
|
||||
app_domain?: string;
|
||||
file_domain?: string;
|
||||
|
||||
41
web/src/api/email.ts
Normal file
41
web/src/api/email.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import request from './client';
|
||||
|
||||
export interface EmailTestPayload {
|
||||
to: string;
|
||||
subject: string;
|
||||
template?: string;
|
||||
context?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export async function sendTestEmail(payload: EmailTestPayload) {
|
||||
return request<{ task_id: string }>('/email/test', {
|
||||
method: 'POST',
|
||||
json: {
|
||||
template: 'test',
|
||||
context: {},
|
||||
...payload,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function listEmailTemplates() {
|
||||
return request<{ templates: string[] }>('/email/templates');
|
||||
}
|
||||
|
||||
export async function getEmailTemplate(name: string) {
|
||||
return request<{ name: string; content: string }>(`/email/templates/${encodeURIComponent(name)}`);
|
||||
}
|
||||
|
||||
export async function updateEmailTemplate(name: string, content: string) {
|
||||
return request(`/email/templates/${encodeURIComponent(name)}`, {
|
||||
method: 'POST',
|
||||
json: { content },
|
||||
});
|
||||
}
|
||||
|
||||
export async function previewEmailTemplate(name: string, context: Record<string, unknown>) {
|
||||
return request<{ html: string }>(`/email/templates/${encodeURIComponent(name)}/preview`, {
|
||||
method: 'POST',
|
||||
json: { context },
|
||||
});
|
||||
}
|
||||
@@ -30,8 +30,8 @@ body { font-family: system-ui,-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto
|
||||
.row-selected td { background: rgba(24,144,255,0.12) !important; }
|
||||
.row-selected:hover td { background: rgba(24,144,255,0.2) !important; }
|
||||
|
||||
.fx-grid { display:flex; flex-wrap:wrap; gap:20px; }
|
||||
.fx-grid-item { width:160px; cursor:pointer; border-radius:14px; padding:12px 12px 10px; background: var(--ant-color-fill-tertiary, #f5f5f5); position:relative; display:flex; flex-direction:column; align-items:stretch; gap:6px; transition:.18s box-shadow,.18s background; }
|
||||
.fx-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(160px,1fr)); gap:20px; align-content:start; }
|
||||
.fx-grid-item { cursor:pointer; border-radius:14px; padding:12px 12px 10px; background: var(--ant-color-fill-tertiary, #f5f5f5); position:relative; display:flex; flex-direction:column; align-items:stretch; gap:6px; transition:.18s box-shadow,.18s background; }
|
||||
.fx-grid-item.dir { background: var(--ant-color-fill-secondary, #f3f3f3); }
|
||||
.fx-grid-item.selected { box-shadow:0 0 0 2px var(--ant-color-primary); background: var(--ant-color-primary-bg, #e6f4ff); }
|
||||
.fx-grid-item:hover { background: var(--ant-color-fill, #ededed); box-shadow:0 1px 4px rgba(0,0,0,.06); }
|
||||
|
||||
@@ -284,7 +284,17 @@ export const en = {
|
||||
'Custom CSS': 'Custom CSS',
|
||||
'Save': 'Save',
|
||||
'App Settings': 'App Settings',
|
||||
'Email Settings': 'Email Settings',
|
||||
'AI Settings': 'AI Settings',
|
||||
'Protocol Mappings': 'Protocol Mappings',
|
||||
'S3 Mapping': 'S3 Mapping',
|
||||
'S3 Endpoint': 'S3 Endpoint',
|
||||
'Bucket Name': 'Bucket Name',
|
||||
'Bucket API Path': 'Bucket API Path',
|
||||
'Region': 'Region',
|
||||
'Base Path': 'Base Path',
|
||||
'Access Key': 'Access Key',
|
||||
'Secret Key': 'Secret Key',
|
||||
'Vision Model': 'Vision Model',
|
||||
'Embedding Model': 'Embedding Model',
|
||||
'Embedding Dimension': 'Embedding Dimension',
|
||||
@@ -328,8 +338,80 @@ export const en = {
|
||||
'Clear Vector DB': 'Clear Vector DB',
|
||||
'App Name': 'App Name',
|
||||
'Logo URL': 'Logo URL',
|
||||
'Favicon URL': 'Favicon URL',
|
||||
'App Domain': 'App Domain',
|
||||
'File Domain': 'File Domain',
|
||||
'Configure Access Key and Secret to enable S3 mapping.': 'Configure Access Key and Secret to enable S3 mapping.',
|
||||
'Mount point inside the virtual file system (e.g. / or /workspace).': 'Mount point inside the virtual file system (e.g. / or /workspace).',
|
||||
'Please input bucket name': 'Please input bucket name',
|
||||
'Please input region': 'Please input region',
|
||||
'Please input access key': 'Please input access key',
|
||||
'Please input secret key': 'Please input secret key',
|
||||
'Save S3 Settings': 'Save S3 Settings',
|
||||
'Example CLI command': 'Example CLI command',
|
||||
'WebDAV Mapping': 'WebDAV Mapping',
|
||||
'WebDAV Endpoint': 'WebDAV Endpoint',
|
||||
'Basic (system account password)': 'Basic (system account password)',
|
||||
'Root Path': 'Root Path',
|
||||
'Client Compatibility': 'Client Compatibility',
|
||||
'Supports Finder, Windows network drive, rclone, and other WebDAV clients.': 'Supports Finder, Windows network drive, rclone, and other WebDAV clients.',
|
||||
'Toggle the switch to expose the virtual file system via WebDAV.': 'Toggle the switch to expose the virtual file system via WebDAV.',
|
||||
'SMTP Settings': 'SMTP Settings',
|
||||
'SMTP Host': 'SMTP Host',
|
||||
'Please input SMTP host': 'Please input SMTP host',
|
||||
'SMTP Port': 'SMTP Port',
|
||||
'Please input SMTP port': 'Please input SMTP port',
|
||||
'Security': 'Security',
|
||||
'None': 'None',
|
||||
'SSL': 'SSL',
|
||||
'STARTTLS': 'STARTTLS',
|
||||
'Timeout (seconds)': 'Timeout (seconds)',
|
||||
'Sender': 'Sender',
|
||||
'Sender Name': 'Sender Name',
|
||||
'Sender Email': 'Sender Email',
|
||||
'Please input sender email': 'Please input sender email',
|
||||
'Authentication': 'Authentication',
|
||||
'SMTP Username': 'SMTP Username',
|
||||
'SMTP Password': 'SMTP Password',
|
||||
'Test Email': 'Test Email',
|
||||
'Current Configuration': 'Current Configuration',
|
||||
'Available variables': 'Available variables',
|
||||
'Not set': 'Not set',
|
||||
'Password Reset Template': 'Password Reset Template',
|
||||
'Live Preview': 'Live Preview',
|
||||
'Foxel Mail Test': 'Foxel Mail Test',
|
||||
'Recipient Address': 'Recipient Address',
|
||||
'Please input recipient email': 'Please input recipient email',
|
||||
'Test Subject': 'Test Subject',
|
||||
'Test User Name': 'Test User Name',
|
||||
'Optional': 'Optional',
|
||||
'Send Test Email': 'Send Test Email',
|
||||
'Please complete all required fields': 'Please complete all required fields',
|
||||
'SMTP port must be a positive number': 'SMTP port must be a positive number',
|
||||
'Test email queued (task {{taskId}})': 'Test email queued (task {{taskId}})',
|
||||
'Test email failed': 'Test email failed',
|
||||
|
||||
// Auth reset
|
||||
'Forgot Password?': 'Forgot password?',
|
||||
'Reset Your Password': 'Reset Your Password',
|
||||
'Enter the email linked to your account and we will send a reset link.': 'Enter the email linked to your account and we will send a reset link.',
|
||||
'If the email exists, a reset link has been sent.': 'If the email exists, a reset link has been sent.',
|
||||
'Send Reset Link': 'Send Reset Link',
|
||||
'Resend Link': 'Resend Link',
|
||||
'Back to login': 'Back to login',
|
||||
'Request failed': 'Request failed',
|
||||
'Reset link is invalid': 'Reset link is invalid',
|
||||
'Reset link is invalid or expired': 'Reset link is invalid or expired',
|
||||
'Reset failed': 'Reset failed',
|
||||
'Try again': 'Try again',
|
||||
'Set a new password': 'Set a new password',
|
||||
'Please enter new password': 'Please enter new password',
|
||||
'Confirm Password': 'Confirm Password',
|
||||
'Please confirm new password': 'Please confirm new password',
|
||||
'Update Password': 'Update Password',
|
||||
'Passwords do not match': 'Passwords do not match',
|
||||
'Password updated, please login again.': 'Password updated, please login again.',
|
||||
'Failed to reset password': 'Failed to reset password',
|
||||
'Vision API URL': 'Vision API URL',
|
||||
'Vision API Key': 'Vision API Key',
|
||||
'Embedding API URL': 'Embedding API URL',
|
||||
@@ -443,6 +525,15 @@ export const en = {
|
||||
'Select adapter type': 'Select adapter type',
|
||||
'/ or /drive': '/ or /drive',
|
||||
'Adapter Config': 'Adapter Config',
|
||||
'adapter.type.local': 'Local Filesystem',
|
||||
'adapter.type.webdav': 'WebDAV',
|
||||
'adapter.type.googledrive': 'Google Drive',
|
||||
'adapter.type.onedrive': 'OneDrive',
|
||||
'adapter.type.s3': 'Amazon S3',
|
||||
'adapter.type.ftp': 'FTP',
|
||||
'adapter.type.sftp': 'SFTP',
|
||||
'adapter.type.telegram': 'Telegram',
|
||||
'adapter.type.quark': 'Quark Drive',
|
||||
|
||||
// Tasks
|
||||
'Automation Tasks': 'Automation Tasks',
|
||||
@@ -469,9 +560,11 @@ export const en = {
|
||||
'Level': 'Level',
|
||||
'Source': 'Source',
|
||||
'Message': 'Message',
|
||||
'User ID': 'User ID',
|
||||
'Search source': 'Search source',
|
||||
'Clear': 'Clear',
|
||||
'Log Details': 'Log Details',
|
||||
'Raw Log': 'Raw Log',
|
||||
|
||||
// Backup
|
||||
'Export started, check your downloads.': 'Export started, check your downloads.',
|
||||
@@ -598,7 +691,6 @@ export const en = {
|
||||
'This is the first account with full permissions': 'This is the first account with full permissions',
|
||||
'Username': 'Username',
|
||||
'Please input a valid email!': 'Please input a valid email!',
|
||||
'Confirm Password': 'Confirm Password',
|
||||
'Please confirm your password!': 'Please confirm your password!',
|
||||
'Passwords do not match!': 'Passwords do not match!',
|
||||
'System Initialization': 'System Initialization',
|
||||
|
||||
@@ -63,12 +63,32 @@ export const zh = {
|
||||
'Sign In': '登录',
|
||||
'Please enter username and password': '请输入用户名与密码',
|
||||
'Login failed': '登录失败',
|
||||
'Forgot Password?': '忘记密码?',
|
||||
'Your next-generation file manager': '您的下一代文件管理系统',
|
||||
'Cross-platform sync, access anywhere': '跨平台同步,随时随地访问',
|
||||
'AI-powered search for quick find': 'AI 驱动的智能搜索,快速定位文件',
|
||||
'Flexible sharing and collaboration': '灵活的分享与协作,提升团队效率',
|
||||
'Powerful automation to simplify tasks': '强大的自动化工作流,简化繁琐任务',
|
||||
'Join our community:': '加入我们的社区:',
|
||||
'Reset Your Password': '重置你的密码',
|
||||
'Enter the email linked to your account and we will send a reset link.': '请输入你账户绑定的邮箱,我们会发送重置链接。',
|
||||
'If the email exists, a reset link has been sent.': '如果邮箱存在,我们已发送重置链接。',
|
||||
'Send Reset Link': '发送重置链接',
|
||||
'Resend Link': '重新发送链接',
|
||||
'Back to login': '返回登录',
|
||||
'Request failed': '请求失败',
|
||||
'Reset link is invalid': '重置链接无效',
|
||||
'Reset link is invalid or expired': '重置链接无效或已过期',
|
||||
'Reset failed': '重置失败',
|
||||
'Try again': '重试',
|
||||
'Set a new password': '设置新密码',
|
||||
'Please enter new password': '请输入新密码',
|
||||
'Confirm Password': '确认新密码',
|
||||
'Please confirm new password': '请确认新密码',
|
||||
'Update Password': '更新密码',
|
||||
'Passwords do not match': '两次输入的密码不一致',
|
||||
'Password updated, please login again.': '密码已更新,请重新登录。',
|
||||
'Failed to reset password': '密码重置失败',
|
||||
|
||||
// Share page
|
||||
'Refresh': '刷新',
|
||||
@@ -285,7 +305,17 @@ export const zh = {
|
||||
'Custom CSS': '自定义 CSS',
|
||||
'Save': '保存',
|
||||
'App Settings': '应用设置',
|
||||
'Email Settings': '邮箱设置',
|
||||
'AI Settings': 'AI设置',
|
||||
'Protocol Mappings': '映射协议',
|
||||
'S3 Mapping': 'S3 映射',
|
||||
'S3 Endpoint': 'S3 访问地址',
|
||||
'Bucket Name': 'Bucket 名称',
|
||||
'Bucket API Path': 'Bucket API 路径',
|
||||
'Region': '区域',
|
||||
'Base Path': '基础路径',
|
||||
'Access Key': 'Access Key',
|
||||
'Secret Key': 'Secret Key',
|
||||
'Choose Template': '选择模板',
|
||||
'Configure Provider': '配置提供商',
|
||||
'Back to Templates': '返回选择',
|
||||
@@ -333,8 +363,62 @@ export const zh = {
|
||||
'Clear Vector DB': '清空向量库',
|
||||
'App Name': '应用名称',
|
||||
'Logo URL': 'LOGO地址',
|
||||
'Favicon URL': 'Favicon 地址',
|
||||
'App Domain': '应用域名',
|
||||
'File Domain': '文件域名',
|
||||
'Configure Access Key and Secret to enable S3 mapping.': '配置 Access Key 与 Secret 后才能启用 S3 映射。',
|
||||
'Mount point inside the virtual file system (e.g. / or /workspace).': '虚拟文件系统中的挂载路径,例如 / 或 /workspace。',
|
||||
'Please input bucket name': '请输入 Bucket 名',
|
||||
'Please input region': '请输入 Region',
|
||||
'Please input access key': '请输入 Access Key',
|
||||
'Please input secret key': '请输入 Secret Key',
|
||||
'Save S3 Settings': '保存 S3 配置',
|
||||
'Example CLI command': '示例 CLI 命令',
|
||||
'WebDAV Mapping': 'WebDAV 映射',
|
||||
'WebDAV Endpoint': 'WebDAV 访问地址',
|
||||
'Basic (system account password)': 'Basic(系统账号密码)',
|
||||
'Root Path': '根路径',
|
||||
'Client Compatibility': '客户端兼容性',
|
||||
'Supports Finder, Windows network drive, rclone, and other WebDAV clients.': '兼容 Finder、Windows 网络驱动器、rclone 等 WebDAV 客户端。',
|
||||
'Toggle the switch to expose the virtual file system via WebDAV.': '通过开关控制是否对外暴露虚拟文件系统的 WebDAV 协议。',
|
||||
'SMTP Settings': 'SMTP 配置',
|
||||
'SMTP Host': 'SMTP 服务器',
|
||||
'Please input SMTP host': '请输入 SMTP 服务器',
|
||||
'SMTP Port': 'SMTP 端口',
|
||||
'Please input SMTP port': '请输入 SMTP 端口',
|
||||
'Security': '安全协议',
|
||||
'None': '无',
|
||||
'SSL': 'SSL',
|
||||
'STARTTLS': 'STARTTLS',
|
||||
'Timeout (seconds)': '超时时间(秒)',
|
||||
'Sender': '发件人',
|
||||
'Sender Name': '发件人名称',
|
||||
'Sender Email': '发件人邮箱',
|
||||
'Please input sender email': '请输入发件人邮箱',
|
||||
'Authentication': '身份认证',
|
||||
'SMTP Username': 'SMTP 用户名',
|
||||
'SMTP Password': 'SMTP 密码',
|
||||
'Test Email': '测试发信',
|
||||
'Current Configuration': '当前配置摘要',
|
||||
'Available variables': '可用变量',
|
||||
'Not set': '未设置',
|
||||
'Password Reset Template': '密码重置模板',
|
||||
'Live Preview': '实时预览',
|
||||
'Template saved': '模板已保存',
|
||||
'Failed to save template': '模板保存失败',
|
||||
'Failed to load template': '模板加载失败',
|
||||
'Preview failed': '预览失败',
|
||||
'Foxel Mail Test': 'Foxel 邮件测试',
|
||||
'Recipient Address': '收件人地址',
|
||||
'Please input recipient email': '请输入收件人邮箱',
|
||||
'Test Subject': '测试邮件标题',
|
||||
'Test User Name': '测试用户名',
|
||||
'Optional': '可选',
|
||||
'Send Test Email': '发送测试邮件',
|
||||
'Please complete all required fields': '请填写所有必填项',
|
||||
'SMTP port must be a positive number': 'SMTP 端口必须为正数',
|
||||
'Test email queued (task {{taskId}})': '测试邮件已入队(任务 {{taskId}})',
|
||||
'Test email failed': '测试邮件发送失败',
|
||||
'Vision API URL': '视觉模型 API 地址',
|
||||
'Vision API Key': '视觉模型 API Key',
|
||||
'Embedding API URL': '嵌入模型 API 地址',
|
||||
@@ -444,6 +528,15 @@ export const zh = {
|
||||
'Select adapter type': '选择适配器类型',
|
||||
'/ or /drive': '/或/drive',
|
||||
'Adapter Config': '适配器配置',
|
||||
'adapter.type.local': '本地文件系统',
|
||||
'adapter.type.webdav': 'WebDAV',
|
||||
'adapter.type.googledrive': 'Google Drive',
|
||||
'adapter.type.onedrive': 'OneDrive',
|
||||
'adapter.type.s3': 'Amazon S3',
|
||||
'adapter.type.ftp': 'FTP',
|
||||
'adapter.type.sftp': 'SFTP',
|
||||
'adapter.type.telegram': 'Telegram',
|
||||
'adapter.type.quark': '夸克网盘',
|
||||
|
||||
// Tasks
|
||||
'Automation Tasks': '自动化任务',
|
||||
@@ -471,9 +564,11 @@ export const zh = {
|
||||
'Level': '级别',
|
||||
'Source': '来源',
|
||||
'Message': '消息',
|
||||
'User ID': '用户 ID',
|
||||
'Search source': '搜索来源',
|
||||
'Clear': '清理',
|
||||
'Log Details': '日志详情',
|
||||
'Raw Log': '原始日志',
|
||||
|
||||
// Backup
|
||||
'Export started, check your downloads.': '导出已开始,请检查您的下载。',
|
||||
@@ -611,7 +706,6 @@ export const zh = {
|
||||
'This is the first account with full permissions': '这是系统的第一个账户,将拥有最高权限。',
|
||||
'Username': '用户名',
|
||||
'Please input a valid email!': '请输入有效的邮箱地址!',
|
||||
'Confirm Password': '确认密码',
|
||||
'Please confirm your password!': '请确认您的密码!',
|
||||
'Passwords do not match!': '两次输入的密码不一致!',
|
||||
'System Initialization': '系统初始化',
|
||||
|
||||
@@ -130,9 +130,16 @@ const AdaptersPage = memo(function AdaptersPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const renderTypeLabel = useCallback((type?: string) => {
|
||||
if (!type) return '-';
|
||||
const key = `adapter.type.${type}`;
|
||||
const label = t(key);
|
||||
return label === key ? type : label;
|
||||
}, [t]);
|
||||
|
||||
const columns = [
|
||||
{ title: t('Name'), dataIndex: 'name' },
|
||||
{ title: t('Type'), dataIndex: 'type', width: 100 },
|
||||
{ title: t('Type'), dataIndex: 'type', width: 140, render: (value: string) => renderTypeLabel(value) },
|
||||
{ title: t('Mount Path'), dataIndex: 'path', width: 140, render: (v: string) => v || '-' },
|
||||
{ title: t('Sub Path'), dataIndex: 'sub_path', width: 140, render: (v: string) => v || '-' },
|
||||
{
|
||||
@@ -233,9 +240,9 @@ const AdaptersPage = memo(function AdaptersPage() {
|
||||
<Form.Item name="type" label={t('Type')} rules={[{ required: true }]}>
|
||||
<Select
|
||||
placeholder={t('Select adapter type')}
|
||||
options={availableTypes.map(t => ({ value: t.type, label: `${t.name} (${t.type})` }))}
|
||||
onChange={() => {
|
||||
const t = availableTypes.find(v => v.type === form.getFieldValue('type'));
|
||||
options={availableTypes.map(t => ({ value: t.type, label: renderTypeLabel(t.type) }))}
|
||||
onChange={(value) => {
|
||||
const t = availableTypes.find(v => v.type === value);
|
||||
const cfgDefaults: Record<string, any> = {};
|
||||
t?.config_schema.forEach(f => {
|
||||
if (f.default !== undefined) cfgDefaults[f.key] = f.default;
|
||||
|
||||
@@ -32,7 +32,9 @@ const FileExplorerPage = memo(function FileExplorerPage() {
|
||||
const { token } = theme.useToken();
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('grid');
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [showSkeleton, setShowSkeleton] = useState(false);
|
||||
const dragCounter = useRef(0);
|
||||
const skeletonTimerRef = useRef<number | null>(null);
|
||||
|
||||
// --- Hooks ---
|
||||
const { path, entries, loading, pagination, processorTypes, sortBy, sortOrder, load, navigateTo, goUp, handlePaginationChange, refresh, handleSortChange } = useFileExplorer(navKey);
|
||||
@@ -63,6 +65,29 @@ const FileExplorerPage = memo(function FileExplorerPage() {
|
||||
load(routePath, 1, pagination.pageSize, sortBy, sortOrder);
|
||||
}, [routePath, navKey, load, pagination.pageSize, sortBy, sortOrder]);
|
||||
|
||||
useEffect(() => {
|
||||
if (skeletonTimerRef.current !== null) {
|
||||
clearTimeout(skeletonTimerRef.current);
|
||||
skeletonTimerRef.current = null;
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
skeletonTimerRef.current = window.setTimeout(() => {
|
||||
setShowSkeleton(true);
|
||||
skeletonTimerRef.current = null;
|
||||
}, 200);
|
||||
} else {
|
||||
setShowSkeleton(false);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (skeletonTimerRef.current !== null) {
|
||||
clearTimeout(skeletonTimerRef.current);
|
||||
skeletonTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [loading]);
|
||||
|
||||
// --- Handlers ---
|
||||
const handleOpenEntry = (entry: VfsEntry) => {
|
||||
if (entry.is_dir) {
|
||||
@@ -184,7 +209,7 @@ const FileExplorerPage = memo(function FileExplorerPage() {
|
||||
/>
|
||||
|
||||
<div style={{ flex: 1, overflow: 'auto', paddingBottom: pagination.total > 0 ? '80px' : '0' }} onContextMenu={openBlankContextMenu}>
|
||||
{loading && (entries.length === 0 || path !== routePath) ? (
|
||||
{showSkeleton && loading && (entries.length === 0 || path !== routePath) ? (
|
||||
<LoadingSkeleton mode={viewMode} />
|
||||
) : !loading && entries.length === 0 ? (
|
||||
<div style={{ textAlign: 'center', padding: 40 }}><EmptyState isRoot={path === '/'} /></div>
|
||||
|
||||
104
web/src/pages/ForgotPasswordPage.tsx
Normal file
104
web/src/pages/ForgotPasswordPage.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import { useState } from 'react';
|
||||
import { Card, Form, Input, Button, Typography, message } from 'antd';
|
||||
import { MailOutlined, ArrowLeftOutlined } from '@ant-design/icons';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { authApi } from '../api/auth';
|
||||
import { useI18n } from '../i18n';
|
||||
import LanguageSwitcher from '../components/LanguageSwitcher';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
export default function ForgotPasswordPage() {
|
||||
const { t } = useI18n();
|
||||
const navigate = useNavigate();
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [sent, setSent] = useState(false);
|
||||
|
||||
const handleSubmit = async (values: { email: string }) => {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await authApi.requestPasswordReset({ email: values.email });
|
||||
message.success(t('If the email exists, a reset link has been sent.'));
|
||||
setSent(true);
|
||||
} catch (err: any) {
|
||||
message.error(err?.message || t('Request failed'));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
minHeight: '100vh',
|
||||
background: 'linear-gradient(to right, var(--ant-color-bg-layout, #f0f2f5), var(--ant-color-fill-secondary, #d7d7d7))',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '48px 16px',
|
||||
position: 'relative'
|
||||
}}>
|
||||
<div style={{ position: 'absolute', top: 16, right: 16 }}>
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
<Card
|
||||
style={{
|
||||
width: '100%',
|
||||
maxWidth: 460,
|
||||
borderRadius: 20,
|
||||
boxShadow: '0 24px 60px rgba(15,23,42,0.12)',
|
||||
border: '1px solid rgba(99,102,241,0.12)',
|
||||
}}
|
||||
styles={{ body: { padding: '40px 36px' } }}
|
||||
>
|
||||
<div style={{ textAlign: 'center', marginBottom: 32 }}>
|
||||
<div style={{
|
||||
width: 64,
|
||||
height: 64,
|
||||
borderRadius: '50%',
|
||||
margin: '0 auto 16px',
|
||||
background: 'linear-gradient(135deg,#6366f1,#8b5cf6)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: '#fff',
|
||||
fontSize: 28,
|
||||
}}>
|
||||
<MailOutlined />
|
||||
</div>
|
||||
<Title level={3} style={{ marginBottom: 8 }}>{t('Reset Your Password')}</Title>
|
||||
<Text type="secondary">
|
||||
{t('Enter the email linked to your account and we will send a reset link.')}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Form layout="vertical" size="large" onFinish={handleSubmit}>
|
||||
<Form.Item
|
||||
name="email"
|
||||
label={t('Email')}
|
||||
rules={[
|
||||
{ required: true, message: t('Please input recipient email') },
|
||||
{ type: 'email', message: t('Please input a valid email!') },
|
||||
]}
|
||||
>
|
||||
<Input placeholder="me@example.com" autoComplete="email" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item style={{ marginTop: 32 }}>
|
||||
<Button type="primary" htmlType="submit" loading={submitting} block>
|
||||
{sent ? t('Resend Link') : t('Send Reset Link')}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<Button
|
||||
type="link"
|
||||
icon={<ArrowLeftOutlined />}
|
||||
onClick={() => navigate('/login')}
|
||||
style={{ padding: 0 }}
|
||||
>
|
||||
{t('Back to login')}
|
||||
</Button>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -107,6 +107,12 @@ export default function LoginPage() {
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item style={{ marginBottom: 8, textAlign: 'right' }}>
|
||||
<Button type="link" onClick={() => navigate('/forgot-password')} style={{ padding: 0 }}>
|
||||
{t('Forgot Password?')}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Button
|
||||
type="primary"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { memo, useState, useEffect, useCallback } from 'react';
|
||||
import { Table, message, Tag, Input, Select, Button, Space, Modal, DatePicker } from 'antd';
|
||||
import { Table, message, Tag, Input, Select, Button, Space, Modal, DatePicker, Descriptions, Divider, Typography } from 'antd';
|
||||
import PageCard from '../components/PageCard';
|
||||
import { logsApi, type LogItem, type PaginatedLogs } from '../api/logs';
|
||||
import { useI18n } from '../i18n';
|
||||
@@ -8,6 +8,7 @@ import { format, formatISO } from 'date-fns';
|
||||
const { RangePicker } = DatePicker;
|
||||
|
||||
const LOG_LEVELS = ['API', 'INFO', 'WARNING', 'ERROR'];
|
||||
const LEVEL_COLOR_MAP: Record<string, string> = { API: 'blue', INFO: 'green', WARNING: 'orange', ERROR: 'red' };
|
||||
|
||||
const LogsPage = memo(function LogsPage() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -74,7 +75,7 @@ const LogsPage = memo(function LogsPage() {
|
||||
dataIndex: 'level',
|
||||
width: 100,
|
||||
render: (level: string) => {
|
||||
const color = { API: 'blue', INFO: 'green', WARNING: 'orange', ERROR: 'red' }[level] || 'default';
|
||||
const color = LEVEL_COLOR_MAP[level] || 'default';
|
||||
return <Tag color={color}>{level}</Tag>;
|
||||
},
|
||||
},
|
||||
@@ -93,9 +94,10 @@ const LogsPage = memo(function LogsPage() {
|
||||
<PageCard
|
||||
title={t('System Logs')}
|
||||
extra={
|
||||
<Space>
|
||||
<Space align="center">
|
||||
<RangePicker
|
||||
showTime
|
||||
size="small"
|
||||
onChange={dates => {
|
||||
setFilters(f => ({
|
||||
...f,
|
||||
@@ -109,6 +111,7 @@ const LogsPage = memo(function LogsPage() {
|
||||
style={{ width: 120 }}
|
||||
placeholder={t('Level')}
|
||||
allowClear
|
||||
size="small"
|
||||
value={filters.level || undefined}
|
||||
onChange={level => setFilters(f => ({ ...f, level: level || '', page: 1 }))}
|
||||
options={LOG_LEVELS.map(l => ({ value: l, label: l }))}
|
||||
@@ -116,6 +119,7 @@ const LogsPage = memo(function LogsPage() {
|
||||
<Input.Search
|
||||
style={{ width: 240 }}
|
||||
placeholder={t('Search source')}
|
||||
size="small"
|
||||
onSearch={source => setFilters(f => ({ ...f, source, page: 1 }))}
|
||||
allowClear
|
||||
/>
|
||||
@@ -145,9 +149,32 @@ const LogsPage = memo(function LogsPage() {
|
||||
width={800}
|
||||
>
|
||||
{selectedLog && (
|
||||
<pre style={{ maxHeight: '60vh', overflow: 'auto', background: 'var(--ant-color-fill-tertiary, #f5f5f5)', padding: 12 }}>
|
||||
{JSON.stringify(selectedLog.details, null, 2)}
|
||||
</pre>
|
||||
<Space direction="vertical" size={16} style={{ width: '100%' }}>
|
||||
<Descriptions column={1} bordered size="small">
|
||||
<Descriptions.Item label={t('Time')}>
|
||||
{format(new Date(selectedLog.timestamp), 'yyyy-MM-dd HH:mm:ss')}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={t('Level')}>
|
||||
<Tag color={LEVEL_COLOR_MAP[selectedLog.level] || 'default'}>{selectedLog.level}</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={t('Source')}>
|
||||
{selectedLog.source}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={t('Message')}>
|
||||
<Typography.Text style={{ whiteSpace: 'pre-wrap' }}>{selectedLog.message}</Typography.Text>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={t('User ID')}>
|
||||
{selectedLog.user_id ?? '-'}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<Divider style={{ margin: '12px 0 0' }} />
|
||||
<Typography.Title level={5} style={{ margin: 0 }}>
|
||||
{t('Raw Log')}
|
||||
</Typography.Title>
|
||||
<pre style={{ maxHeight: '60vh', overflow: 'auto', background: 'var(--ant-color-fill-tertiary, #f5f5f5)', padding: 12 }}>
|
||||
{JSON.stringify(selectedLog, null, 2)}
|
||||
</pre>
|
||||
</Space>
|
||||
)}
|
||||
</Modal>
|
||||
</PageCard>
|
||||
|
||||
@@ -297,14 +297,14 @@ const ProcessorsPage = memo(function ProcessorsPage() {
|
||||
const renderProcessorList = () => {
|
||||
if (loadingList) {
|
||||
return (
|
||||
<Flex align="center" justify="center" style={{ height: '100%' }}>
|
||||
<Flex align="center" justify="center" style={{ height: '100%', width: '100%' }}>
|
||||
<Spin />
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
if (!processors.length) {
|
||||
return (
|
||||
<Flex align="center" justify="center" style={{ height: '100%' }}>
|
||||
<Flex align="center" justify="center" style={{ height: '100%', width: '100%' }}>
|
||||
<Empty description={t('No data')} />
|
||||
</Flex>
|
||||
);
|
||||
@@ -385,7 +385,7 @@ const ProcessorsPage = memo(function ProcessorsPage() {
|
||||
</div>
|
||||
<div style={{ flex: 1, minHeight: 0 }}>
|
||||
{sourceLoading ? (
|
||||
<Flex align="center" justify="center" style={{ height: '100%' }}>
|
||||
<Flex align="center" justify="center" style={{ height: '100%', width: '100%' }}>
|
||||
<Spin />
|
||||
</Flex>
|
||||
) : (
|
||||
|
||||
146
web/src/pages/ResetPasswordPage.tsx
Normal file
146
web/src/pages/ResetPasswordPage.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Card, Form, Input, Button, Typography, message, Result } from 'antd';
|
||||
import { LockOutlined, CheckCircleTwoTone } from '@ant-design/icons';
|
||||
import { useLocation, useNavigate } from 'react-router';
|
||||
import { authApi } from '../api/auth';
|
||||
import { useI18n } from '../i18n';
|
||||
import LanguageSwitcher from '../components/LanguageSwitcher';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
export default function ResetPasswordPage() {
|
||||
const { t } = useI18n();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const token = useMemo(() => new URLSearchParams(location.search).get('token') || '', [location.search]);
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [userInfo, setUserInfo] = useState<{ username: string; email: string } | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
setError(t('Reset link is invalid'));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
authApi.verifyPasswordResetToken(token)
|
||||
.then(setUserInfo)
|
||||
.catch((err) => {
|
||||
setError(err?.message || t('Reset link is invalid or expired'));
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [token, t]);
|
||||
|
||||
const handleSubmit = async (values: { password: string; confirm: string }) => {
|
||||
if (values.password !== values.confirm) {
|
||||
message.error(t('Passwords do not match'));
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await authApi.confirmPasswordReset({ token, password: values.password });
|
||||
setSuccess(true);
|
||||
message.success(t('Password updated, please login again.'));
|
||||
setTimeout(() => navigate('/login'), 1500);
|
||||
} catch (err: any) {
|
||||
message.error(err?.message || t('Failed to reset password'));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Result
|
||||
status="error"
|
||||
title={t('Reset failed')}
|
||||
subTitle={error}
|
||||
extra={[
|
||||
<Button type="primary" key="back" onClick={() => navigate('/forgot-password')}>
|
||||
{t('Try again')}
|
||||
</Button>,
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
minHeight: '100vh',
|
||||
background: 'linear-gradient(to right, var(--ant-color-bg-layout, #f0f2f5), var(--ant-color-fill-secondary, #d7d7d7))',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '48px 16px',
|
||||
position: 'relative'
|
||||
}}>
|
||||
<div style={{ position: 'absolute', top: 16, right: 16 }}>
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
<Card
|
||||
style={{
|
||||
width: '100%',
|
||||
maxWidth: 480,
|
||||
borderRadius: 20,
|
||||
border: '1px solid rgba(99,102,241,0.14)',
|
||||
boxShadow: '0 24px 60px rgba(79,70,229,0.18)',
|
||||
}}
|
||||
bodyStyle={{ padding: '40px 36px' }}
|
||||
>
|
||||
<div style={{ textAlign: 'center', marginBottom: 32 }}>
|
||||
<div style={{
|
||||
width: 64,
|
||||
height: 64,
|
||||
borderRadius: '50%',
|
||||
margin: '0 auto 16px',
|
||||
background: success ? '#ecfdf5' : 'linear-gradient(135deg,#6366f1,#8b5cf6)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: success ? '#047857' : '#fff',
|
||||
fontSize: success ? 32 : 28,
|
||||
}}>
|
||||
{success ? <CheckCircleTwoTone twoToneColor="#22c55e" /> : <LockOutlined />}
|
||||
</div>
|
||||
<Title level={3} style={{ marginBottom: 8 }}>{t('Set a new password')}</Title>
|
||||
{userInfo && <Text type="secondary">{userInfo.email}</Text>}
|
||||
</div>
|
||||
|
||||
<Form layout="vertical" size="large" onFinish={handleSubmit}>
|
||||
<Form.Item
|
||||
name="password"
|
||||
label={t('New Password')}
|
||||
rules={[{ required: true, message: t('Please enter new password') }]}
|
||||
>
|
||||
<Input.Password autoComplete="new-password" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="confirm"
|
||||
label={t('Confirm Password')}
|
||||
rules={[{ required: true, message: t('Please confirm new password') }]}
|
||||
>
|
||||
<Input.Password autoComplete="new-password" />
|
||||
</Form.Item>
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
loading={submitting}
|
||||
block
|
||||
size="large"
|
||||
>
|
||||
{t('Update Password')}
|
||||
</Button>
|
||||
</Form>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { message, Tabs, Space } from 'antd';
|
||||
import { useEffect, useState } from 'react';
|
||||
import PageCard from '../../components/PageCard';
|
||||
import { getAllConfig, setConfig } from '../../api/config';
|
||||
import { AppstoreOutlined, RobotOutlined, DatabaseOutlined, SkinOutlined } from '@ant-design/icons';
|
||||
import { AppstoreOutlined, RobotOutlined, DatabaseOutlined, SkinOutlined, MailOutlined, CloudSyncOutlined } from '@ant-design/icons';
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
import '../../styles/settings-tabs.css';
|
||||
import { useI18n } from '../../i18n';
|
||||
@@ -10,10 +10,12 @@ import AppearanceSettingsTab from './components/AppearanceSettingsTab';
|
||||
import AppSettingsTab from './components/AppSettingsTab';
|
||||
import AiSettingsTab from './components/AiSettingsTab';
|
||||
import VectorDbSettingsTab from './components/VectorDbSettingsTab';
|
||||
import EmailSettingsTab from './components/EmailSettingsTab';
|
||||
import ProtocolMappingsTab from './components/ProtocolMappingsTab';
|
||||
|
||||
type TabKey = 'appearance' | 'app' | 'ai' | 'vector-db';
|
||||
type TabKey = 'appearance' | 'app' | 'email' | 'ai' | 'vector-db' | 'mappings';
|
||||
|
||||
const TAB_KEYS: TabKey[] = ['appearance', 'app', 'ai', 'vector-db'];
|
||||
const TAB_KEYS: TabKey[] = ['appearance', 'app', 'email', 'ai', 'vector-db', 'mappings'];
|
||||
const DEFAULT_TAB: TabKey = 'appearance';
|
||||
|
||||
const isValidTab = (key?: string): key is TabKey => !!key && (TAB_KEYS as string[]).includes(key);
|
||||
@@ -26,6 +28,7 @@ interface SystemSettingsPageProps {
|
||||
const APP_CONFIG_KEYS: { key: string, label: string, default?: string }[] = [
|
||||
{ key: 'APP_NAME', label: 'App Name' },
|
||||
{ key: 'APP_LOGO', label: 'Logo URL' },
|
||||
{ key: 'APP_FAVICON', label: 'Favicon URL', default: '/logo.svg' },
|
||||
{ key: 'APP_DOMAIN', label: 'App Domain' },
|
||||
{ key: 'FILE_DOMAIN', label: 'File Domain' },
|
||||
];
|
||||
@@ -107,13 +110,12 @@ export default function SystemSettingsPage({ tabKey, onTabNavigate }: SystemSett
|
||||
<PageCard
|
||||
title={t('System Settings')}
|
||||
>
|
||||
<Space direction="vertical" style={{ width: '100%' }} size={32}>
|
||||
<Space direction="vertical" style={{ width: '100%' }} size={16}>
|
||||
<Tabs
|
||||
className="fx-settings-tabs"
|
||||
activeKey={activeTab}
|
||||
onChange={handleTabChange}
|
||||
centered
|
||||
tabPosition="left"
|
||||
items={[
|
||||
{
|
||||
key: 'appearance',
|
||||
@@ -149,6 +151,22 @@ export default function SystemSettingsPage({ tabKey, onTabNavigate }: SystemSett
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'email',
|
||||
label: (
|
||||
<span>
|
||||
<MailOutlined style={{ marginRight: 8 }} />
|
||||
{t('Email Settings')}
|
||||
</span>
|
||||
),
|
||||
children: (
|
||||
<EmailSettingsTab
|
||||
config={config}
|
||||
loading={loading}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'ai',
|
||||
label: (
|
||||
@@ -174,6 +192,22 @@ export default function SystemSettingsPage({ tabKey, onTabNavigate }: SystemSett
|
||||
<VectorDbSettingsTab isActive={activeTab === 'vector-db'} />
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'mappings',
|
||||
label: (
|
||||
<span>
|
||||
<CloudSyncOutlined style={{ marginRight: 8 }} />
|
||||
{t('Protocol Mappings')}
|
||||
</span>
|
||||
),
|
||||
children: (
|
||||
<ProtocolMappingsTab
|
||||
config={config}
|
||||
loading={loading}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Space>
|
||||
|
||||
440
web/src/pages/SystemSettingsPage/components/EmailSettingsTab.tsx
Normal file
440
web/src/pages/SystemSettingsPage/components/EmailSettingsTab.tsx
Normal file
@@ -0,0 +1,440 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
Descriptions,
|
||||
Divider,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Row,
|
||||
Select,
|
||||
Space,
|
||||
Typography,
|
||||
message,
|
||||
Tag,
|
||||
Skeleton,
|
||||
} from 'antd';
|
||||
import { HighlightOutlined, EyeOutlined, SaveOutlined, SendOutlined, InfoCircleOutlined } from '@ant-design/icons';
|
||||
import { useI18n } from '../../../i18n';
|
||||
import {
|
||||
sendTestEmail,
|
||||
getEmailTemplate,
|
||||
updateEmailTemplate,
|
||||
previewEmailTemplate,
|
||||
} from '../../../api/email';
|
||||
|
||||
interface EmailSettingsTabProps {
|
||||
config: Record<string, string>;
|
||||
loading: boolean;
|
||||
onSave: (values: Record<string, unknown>) => Promise<void>;
|
||||
}
|
||||
|
||||
interface EmailFormValues {
|
||||
host: string;
|
||||
port: number;
|
||||
username?: string;
|
||||
password?: string;
|
||||
sender_name?: string;
|
||||
sender_email: string;
|
||||
security: 'none' | 'ssl' | 'starttls';
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
interface TestFormValues {
|
||||
to: string;
|
||||
subject: string;
|
||||
username?: string;
|
||||
}
|
||||
|
||||
interface PreviewContext extends Record<string, unknown> {
|
||||
username: string;
|
||||
reset_link: string;
|
||||
expire_minutes: number;
|
||||
}
|
||||
|
||||
const DEFAULT_FORM: EmailFormValues = {
|
||||
host: '',
|
||||
port: 465,
|
||||
username: '',
|
||||
password: '',
|
||||
sender_name: '',
|
||||
sender_email: '',
|
||||
security: 'ssl',
|
||||
timeout: 30,
|
||||
};
|
||||
|
||||
const TEMPLATE_NAME = 'password_reset';
|
||||
|
||||
function parseEmailConfig(raw?: string | null): EmailFormValues {
|
||||
if (!raw) return { ...DEFAULT_FORM };
|
||||
try {
|
||||
const data = JSON.parse(raw) as Partial<EmailFormValues>;
|
||||
return {
|
||||
...DEFAULT_FORM,
|
||||
...data,
|
||||
port: Number(data?.port ?? DEFAULT_FORM.port),
|
||||
timeout: data?.timeout !== undefined ? Number(data.timeout) : DEFAULT_FORM.timeout,
|
||||
security: (data?.security ?? DEFAULT_FORM.security) as EmailFormValues['security'],
|
||||
};
|
||||
} catch (_err) {
|
||||
return { ...DEFAULT_FORM };
|
||||
}
|
||||
}
|
||||
|
||||
export default function EmailSettingsTab({ config, loading, onSave }: EmailSettingsTabProps) {
|
||||
const { t } = useI18n();
|
||||
const [testForm] = Form.useForm<TestFormValues>();
|
||||
const [previewForm] = Form.useForm<PreviewContext>();
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [template, setTemplate] = useState<string>('');
|
||||
const [templateLoading, setTemplateLoading] = useState(true);
|
||||
const [templateSaving, setTemplateSaving] = useState(false);
|
||||
const [previewing, setPreviewing] = useState(false);
|
||||
const [previewHtml, setPreviewHtml] = useState<string>('');
|
||||
|
||||
const initialValues = useMemo(() => parseEmailConfig(config?.EMAIL_CONFIG), [config]);
|
||||
|
||||
const summary = useMemo(() => {
|
||||
const parsed = parseEmailConfig(config?.EMAIL_CONFIG);
|
||||
return [
|
||||
{ label: t('SMTP Host'), value: parsed.host || '-' },
|
||||
{ label: t('SMTP Port'), value: parsed.port || '-' },
|
||||
{ label: t('Security'), value: parsed.security.toUpperCase() },
|
||||
{ label: t('Sender Email'), value: parsed.sender_email || '-' },
|
||||
{ label: t('Sender Name'), value: parsed.sender_name || t('Not set') },
|
||||
{ label: t('Timeout (seconds)'), value: parsed.timeout || '-' },
|
||||
];
|
||||
}, [config, t]);
|
||||
|
||||
useEffect(() => {
|
||||
setTemplateLoading(true);
|
||||
getEmailTemplate(TEMPLATE_NAME)
|
||||
.then((res) => setTemplate(res.content))
|
||||
.catch((err) => {
|
||||
message.error(err?.message || t('Failed to load template'));
|
||||
})
|
||||
.finally(() => setTemplateLoading(false));
|
||||
}, [t]);
|
||||
|
||||
useEffect(() => {
|
||||
previewForm.setFieldsValue({
|
||||
username: 'Foxel 用户',
|
||||
reset_link: 'https://foxel.cc/reset-password?token=demo',
|
||||
expire_minutes: 10,
|
||||
});
|
||||
}, [previewForm]);
|
||||
|
||||
const handleSaveConfig = async (values: EmailFormValues) => {
|
||||
if (!values.host || !values.port || !values.sender_email) {
|
||||
message.error(t('Please complete all required fields'));
|
||||
return;
|
||||
}
|
||||
const payload: Record<string, unknown> = {
|
||||
host: values.host.trim(),
|
||||
port: Number(values.port),
|
||||
sender_email: values.sender_email.trim(),
|
||||
security: values.security,
|
||||
};
|
||||
if (!Number.isFinite(payload.port as number) || (payload.port as number) <= 0) {
|
||||
message.error(t('SMTP port must be a positive number'));
|
||||
return;
|
||||
}
|
||||
if (values.username?.trim()) {
|
||||
payload.username = values.username.trim();
|
||||
}
|
||||
if (values.password?.length) {
|
||||
payload.password = values.password;
|
||||
}
|
||||
if (values.sender_name?.trim()) {
|
||||
payload.sender_name = values.sender_name.trim();
|
||||
}
|
||||
if (values.timeout !== undefined && values.timeout !== null) {
|
||||
const timeoutNumber = Number(values.timeout);
|
||||
if (Number.isFinite(timeoutNumber) && timeoutNumber > 0) {
|
||||
payload.timeout = timeoutNumber;
|
||||
}
|
||||
}
|
||||
await onSave({ EMAIL_CONFIG: JSON.stringify(payload) });
|
||||
};
|
||||
|
||||
const handleTest = async () => {
|
||||
try {
|
||||
const values = await testForm.validateFields();
|
||||
setTesting(true);
|
||||
const response = await sendTestEmail({
|
||||
to: values.to,
|
||||
subject: values.subject,
|
||||
template: 'test',
|
||||
context: { username: values.username || values.to },
|
||||
});
|
||||
message.success(t('Test email queued (task {{taskId}})', { taskId: response.task_id }));
|
||||
} catch (err: any) {
|
||||
if (err?.errorFields) {
|
||||
return;
|
||||
}
|
||||
message.error(err?.message || t('Test email failed'));
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePreviewTemplate = async () => {
|
||||
try {
|
||||
const values = await previewForm.validateFields();
|
||||
setPreviewing(true);
|
||||
const res = await previewEmailTemplate(TEMPLATE_NAME, values);
|
||||
setPreviewHtml(res.html);
|
||||
} catch (err: any) {
|
||||
if (err?.errorFields) return;
|
||||
message.error(err?.message || t('Preview failed'));
|
||||
} finally {
|
||||
setPreviewing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveTemplate = async () => {
|
||||
setTemplateSaving(true);
|
||||
try {
|
||||
await updateEmailTemplate(TEMPLATE_NAME, template);
|
||||
message.success(t('Template saved'));
|
||||
} catch (err: any) {
|
||||
message.error(err?.message || t('Failed to save template'));
|
||||
} finally {
|
||||
setTemplateSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Space direction="vertical" size={32} style={{ width: '100%', marginTop: 24 }}>
|
||||
<Row gutter={24}>
|
||||
<Col xs={24} lg={15}>
|
||||
<Card
|
||||
title={t('SMTP Settings')}
|
||||
extra={<InfoCircleOutlined style={{ color: 'var(--ant-color-primary)' }} />}
|
||||
bodyStyle={{ paddingBottom: 12 }}
|
||||
>
|
||||
<Form<EmailFormValues>
|
||||
layout="vertical"
|
||||
initialValues={initialValues}
|
||||
onFinish={handleSaveConfig}
|
||||
key={'email-settings-' + (config?.EMAIL_CONFIG ?? '')}
|
||||
>
|
||||
<Row gutter={16}>
|
||||
<Col span={14}>
|
||||
<Form.Item
|
||||
name="host"
|
||||
label={t('SMTP Host')}
|
||||
rules={[{ required: true, message: t('Please input SMTP host') }]}
|
||||
>
|
||||
<Input size="large" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={10}>
|
||||
<Form.Item
|
||||
name="port"
|
||||
label={t('SMTP Port')}
|
||||
rules={[{ required: true, message: t('Please input SMTP port') }]}
|
||||
>
|
||||
<InputNumber min={1} style={{ width: '100%' }} size="large" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item name="security" label={t('Security')}>
|
||||
<Select
|
||||
size="large"
|
||||
options={[
|
||||
{ value: 'none', label: t('None') },
|
||||
{ value: 'ssl', label: 'SSL' },
|
||||
{ value: 'starttls', label: 'STARTTLS' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="timeout" label={t('Timeout (seconds)')}>
|
||||
<InputNumber min={1} style={{ width: '100%' }} size="large" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Divider />
|
||||
<Form.Item name="sender_name" label={t('Sender Name')}>
|
||||
<Input size="large" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="sender_email"
|
||||
label={t('Sender Email')}
|
||||
rules={[
|
||||
{ required: true, message: t('Please input sender email') },
|
||||
{ type: 'email', message: t('Please input a valid email!') },
|
||||
]}
|
||||
>
|
||||
<Input size="large" />
|
||||
</Form.Item>
|
||||
<Divider />
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item name="username" label={t('SMTP Username')}>
|
||||
<Input size="large" autoComplete="username" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="password" label={t('SMTP Password')}>
|
||||
<Input.Password size="large" autoComplete="current-password" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Form.Item style={{ marginTop: 24 }}>
|
||||
<Button type="primary" htmlType="submit" loading={loading} icon={<SaveOutlined />} block>
|
||||
{t('Save')}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} lg={9}>
|
||||
<Space direction="vertical" size={24} style={{ width: '100%' }}>
|
||||
<Card title={t('Current Configuration')} bodyStyle={{ paddingBottom: 12 }}>
|
||||
<Descriptions column={1} size="small" colon={false}>
|
||||
{summary.map(item => (
|
||||
<Descriptions.Item key={item.label} label={<TextLabel text={item.label} />}>
|
||||
<Typography.Text strong>{item.value}</Typography.Text>
|
||||
</Descriptions.Item>
|
||||
))}
|
||||
</Descriptions>
|
||||
</Card>
|
||||
<Card title={t('Test Email')} extra={<SendOutlined style={{ color: 'var(--ant-color-primary)' }} />}>
|
||||
<Form<TestFormValues>
|
||||
form={testForm}
|
||||
layout="vertical"
|
||||
initialValues={{
|
||||
subject: t('Foxel Mail Test'),
|
||||
username: '',
|
||||
}}
|
||||
>
|
||||
<Form.Item
|
||||
name="to"
|
||||
label={t('Recipient Address')}
|
||||
rules={[
|
||||
{ required: true, message: t('Please input recipient email') },
|
||||
{ type: 'email', message: t('Please input a valid email!') },
|
||||
]}
|
||||
>
|
||||
<Input size="large" />
|
||||
</Form.Item>
|
||||
<Form.Item name="subject" label={t('Test Subject')}>
|
||||
<Input size="large" />
|
||||
</Form.Item>
|
||||
<Form.Item name="username" label={t('Test User Name')}>
|
||||
<Input size="large" placeholder={t('Optional')} />
|
||||
</Form.Item>
|
||||
<Button type="primary" onClick={handleTest} loading={testing} block icon={<SendOutlined />}>
|
||||
{t('Send Test Email')}
|
||||
</Button>
|
||||
</Form>
|
||||
</Card>
|
||||
</Space>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Card
|
||||
title={
|
||||
<Space>
|
||||
<HighlightOutlined />
|
||||
{t('Password Reset Template')}
|
||||
</Space>
|
||||
}
|
||||
extra={
|
||||
<Space>
|
||||
<Button icon={<EyeOutlined />} onClick={handlePreviewTemplate} loading={previewing}>
|
||||
{t('Preview')}
|
||||
</Button>
|
||||
<Button type="primary" icon={<SaveOutlined />} onClick={handleSaveTemplate} loading={templateSaving}>
|
||||
{t('Save')}
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Row gutter={24}>
|
||||
<Col xs={24} lg={14}>
|
||||
{templateLoading ? (
|
||||
<Skeleton active paragraph={{ rows: 8 }} />
|
||||
) : (
|
||||
<Input.TextArea
|
||||
value={template}
|
||||
onChange={(e) => setTemplate(e.target.value)}
|
||||
autoSize={{ minRows: 20 }}
|
||||
style={{ fontFamily: 'monospace', background: '#0f172a', color: '#e2e8f0' }}
|
||||
/>
|
||||
)}
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<Typography.Text type="secondary">{t('Available variables')}:</Typography.Text>
|
||||
<Space wrap style={{ marginTop: 8 }}>
|
||||
<Tag color="blue">${'{username}'}</Tag>
|
||||
<Tag color="blue">${'{reset_link}'}</Tag>
|
||||
<Tag color="blue">${'{expire_minutes}'}</Tag>
|
||||
</Space>
|
||||
</div>
|
||||
</Col>
|
||||
<Col xs={24} lg={10}>
|
||||
<Card title={t('Preview Context')} size="small" style={{ marginBottom: 16 }}>
|
||||
<Form<PreviewContext> layout="vertical" form={previewForm}>
|
||||
<Form.Item
|
||||
name="username"
|
||||
label="username"
|
||||
rules={[{ required: true, message: t('Please complete all required fields') }]}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="reset_link"
|
||||
label="reset_link"
|
||||
rules={[{ required: true, message: t('Please complete all required fields') }]}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="expire_minutes"
|
||||
label="expire_minutes"
|
||||
rules={[{ required: true, message: t('Please complete all required fields') }]}
|
||||
>
|
||||
<InputNumber min={1} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
<Card title={t('Live Preview')} size="small" className="email-template-preview">
|
||||
<div
|
||||
style={{
|
||||
border: '1px solid rgba(148,163,184,0.2)',
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden',
|
||||
height: 360,
|
||||
background: '#f8fafc',
|
||||
padding: 0,
|
||||
}}
|
||||
>
|
||||
<iframe
|
||||
title="email-preview"
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
border: 'none',
|
||||
backgroundColor: '#f8fafc',
|
||||
}}
|
||||
srcDoc={previewHtml || template}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
|
||||
function TextLabel({ text }: { text: string }) {
|
||||
return <Typography.Text type="secondary">{text}</Typography.Text>;
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Alert, Button, Card, Descriptions, Form, Input, Space, Switch, Typography } from 'antd';
|
||||
import { useI18n } from '../../../i18n';
|
||||
|
||||
interface ProtocolMappingsTabProps {
|
||||
config: Record<string, string>;
|
||||
loading: boolean;
|
||||
onSave: (values: Record<string, unknown>) => Promise<void>;
|
||||
}
|
||||
|
||||
const WEBDAV_KEY = 'WEBDAV_MAPPING_ENABLED';
|
||||
const S3_KEYS = {
|
||||
ENABLED: 'S3_MAPPING_ENABLED',
|
||||
BUCKET: 'S3_MAPPING_BUCKET',
|
||||
REGION: 'S3_MAPPING_REGION',
|
||||
BASE_PATH: 'S3_MAPPING_BASE_PATH',
|
||||
ACCESS_KEY: 'S3_MAPPING_ACCESS_KEY',
|
||||
SECRET_KEY: 'S3_MAPPING_SECRET_KEY',
|
||||
};
|
||||
|
||||
const truthy = new Set(['1', 'true', 'yes', 'on']);
|
||||
|
||||
export default function ProtocolMappingsTab({ config, loading, onSave }: ProtocolMappingsTabProps) {
|
||||
const { t } = useI18n();
|
||||
const [webdavEnabled, setWebdavEnabled] = useState(() => truthy.has((config[WEBDAV_KEY] ?? '1').toLowerCase()));
|
||||
const [webdavSaving, setWebdavSaving] = useState(false);
|
||||
const [s3Enabled, setS3Enabled] = useState(() => truthy.has((config[S3_KEYS.ENABLED] ?? '1').toLowerCase()));
|
||||
const [s3ToggleSaving, setS3ToggleSaving] = useState(false);
|
||||
const [s3FormSaving, setS3FormSaving] = useState(false);
|
||||
const [s3Form] = Form.useForm();
|
||||
const watchBucket = Form.useWatch('bucket', s3Form);
|
||||
const watchRegion = Form.useWatch('region', s3Form);
|
||||
const watchBasePath = Form.useWatch('basePath', s3Form);
|
||||
const watchAccessKey = Form.useWatch('accessKey', s3Form);
|
||||
const watchSecretKey = Form.useWatch('secretKey', s3Form);
|
||||
|
||||
useEffect(() => {
|
||||
setWebdavEnabled(truthy.has((config[WEBDAV_KEY] ?? '1').toLowerCase()));
|
||||
setS3Enabled(truthy.has((config[S3_KEYS.ENABLED] ?? '1').toLowerCase()));
|
||||
s3Form.setFieldsValue({
|
||||
bucket: config[S3_KEYS.BUCKET] ?? 'foxel',
|
||||
region: config[S3_KEYS.REGION] ?? 'us-east-1',
|
||||
basePath: config[S3_KEYS.BASE_PATH] ?? '/',
|
||||
accessKey: config[S3_KEYS.ACCESS_KEY] ?? '',
|
||||
secretKey: config[S3_KEYS.SECRET_KEY] ?? '',
|
||||
});
|
||||
}, [config, s3Form]);
|
||||
|
||||
const webdavEndpoint = useMemo(() => {
|
||||
const configured = (config.APP_DOMAIN ?? '').trim();
|
||||
if (configured) {
|
||||
const hasProtocol = configured.startsWith('http://') || configured.startsWith('https://');
|
||||
const base = hasProtocol ? configured : `https://${configured}`;
|
||||
return base.replace(/\/$/, '') + '/webdav';
|
||||
}
|
||||
if (typeof window !== 'undefined') {
|
||||
return window.location.origin.replace(/\/$/, '') + '/webdav';
|
||||
}
|
||||
return '/webdav';
|
||||
}, [config.APP_DOMAIN]);
|
||||
|
||||
const baseOrigin = useMemo(() => {
|
||||
const configured = (config.APP_DOMAIN ?? '').trim();
|
||||
if (configured) {
|
||||
const hasProtocol = configured.startsWith('http://') || configured.startsWith('https://');
|
||||
return (hasProtocol ? configured : `https://${configured}`).replace(/\/$/, '');
|
||||
}
|
||||
if (typeof window !== 'undefined') {
|
||||
return window.location.origin.replace(/\/$/, '');
|
||||
}
|
||||
return '';
|
||||
}, [config.APP_DOMAIN]);
|
||||
|
||||
const bucketValue = (watchBucket ?? config[S3_KEYS.BUCKET] ?? 'foxel').trim() || 'foxel';
|
||||
const s3Endpoint = useMemo(() => {
|
||||
if (!baseOrigin) return '/s3';
|
||||
return `${baseOrigin.replace(/\/$/, '')}/s3`;
|
||||
}, [baseOrigin]);
|
||||
const bucketApiPath = useMemo(() => `${s3Endpoint.replace(/\/$/, '')}/${encodeURIComponent(bucketValue)}`, [s3Endpoint, bucketValue]);
|
||||
|
||||
const handleToggleS3 = async (checked: boolean) => {
|
||||
setS3ToggleSaving(true);
|
||||
try {
|
||||
await onSave({ [S3_KEYS.ENABLED]: checked ? '1' : '0' });
|
||||
setS3Enabled(checked);
|
||||
} finally {
|
||||
setS3ToggleSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const normalizeBasePath = (value?: string) => {
|
||||
const trimmed = (value ?? '/').trim();
|
||||
if (!trimmed) return '/';
|
||||
if (!trimmed.startsWith('/')) {
|
||||
return `/${trimmed}`;
|
||||
}
|
||||
return trimmed.replace(/\/+$/, '') || '/';
|
||||
};
|
||||
|
||||
const regionValue = (watchRegion ?? config[S3_KEYS.REGION] ?? 'us-east-1').trim() || 'us-east-1';
|
||||
const basePathValue = normalizeBasePath(watchBasePath ?? config[S3_KEYS.BASE_PATH] ?? '/');
|
||||
const accessKeyValue = (watchAccessKey ?? config[S3_KEYS.ACCESS_KEY] ?? '').trim();
|
||||
const secretValue = (watchSecretKey ?? config[S3_KEYS.SECRET_KEY] ?? '').trim();
|
||||
const exampleCommand = `aws --endpoint-url ${s3Endpoint} s3 ls s3://${bucketValue}/`;
|
||||
|
||||
const handleSaveS3 = async (values: Record<string, string>) => {
|
||||
setS3FormSaving(true);
|
||||
try {
|
||||
await onSave({
|
||||
[S3_KEYS.BUCKET]: values.bucket?.trim() || 'foxel',
|
||||
[S3_KEYS.REGION]: values.region?.trim() || 'us-east-1',
|
||||
[S3_KEYS.BASE_PATH]: normalizeBasePath(values.basePath),
|
||||
[S3_KEYS.ACCESS_KEY]: values.accessKey?.trim() || '',
|
||||
[S3_KEYS.SECRET_KEY]: values.secretKey?.trim() || '',
|
||||
});
|
||||
} finally {
|
||||
setS3FormSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const hasS3Credentials = Boolean(accessKeyValue && secretValue);
|
||||
|
||||
const handleToggleWebdav = async (checked: boolean) => {
|
||||
setWebdavSaving(true);
|
||||
try {
|
||||
await onSave({ [WEBDAV_KEY]: checked ? '1' : '0' });
|
||||
setWebdavEnabled(checked);
|
||||
} finally {
|
||||
setWebdavSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Space direction="vertical" size={16} style={{ width: '100%' }}>
|
||||
<Card
|
||||
title={t('WebDAV Mapping')}
|
||||
extra={(
|
||||
<Space size={12} align="center">
|
||||
<Switch
|
||||
checked={webdavEnabled}
|
||||
loading={webdavSaving}
|
||||
disabled={loading}
|
||||
onChange={handleToggleWebdav}
|
||||
/>
|
||||
</Space>
|
||||
)}
|
||||
>
|
||||
<Descriptions
|
||||
column={1}
|
||||
size="small"
|
||||
items={[
|
||||
{
|
||||
key: 'endpoint',
|
||||
label: t('WebDAV Endpoint'),
|
||||
children: (
|
||||
<Typography.Text copyable={{ text: webdavEndpoint }}>
|
||||
<code>{webdavEndpoint}</code>
|
||||
</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'auth',
|
||||
label: t('Authentication'),
|
||||
children: t('Basic (system account password)'),
|
||||
},
|
||||
{
|
||||
key: 'root',
|
||||
label: t('Root Path'),
|
||||
children: '/webdav',
|
||||
},
|
||||
{
|
||||
key: 'compat',
|
||||
label: t('Client Compatibility'),
|
||||
children: t('Supports Finder, Windows network drive, rclone, and other WebDAV clients.'),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Typography.Text type="secondary">
|
||||
{t('Toggle the switch to expose the virtual file system via WebDAV.')}
|
||||
</Typography.Text>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title={t('S3 Mapping')}
|
||||
extra={(
|
||||
<Switch
|
||||
checked={s3Enabled}
|
||||
loading={s3ToggleSaving}
|
||||
disabled={loading}
|
||||
onChange={handleToggleS3}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<Space direction="vertical" size={16} style={{ width: '100%' }}>
|
||||
{!hasS3Credentials && (
|
||||
<Alert
|
||||
type="warning"
|
||||
message={t('Configure Access Key and Secret to enable S3 mapping.')}
|
||||
showIcon
|
||||
/>
|
||||
)}
|
||||
<Descriptions
|
||||
column={1}
|
||||
size="small"
|
||||
items={[
|
||||
{
|
||||
key: 'endpoint',
|
||||
label: t('S3 Endpoint'),
|
||||
children: (
|
||||
<Typography.Text copyable={{ text: s3Endpoint }}>
|
||||
<code>{s3Endpoint}</code>
|
||||
</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'bucket',
|
||||
label: t('Bucket Name'),
|
||||
children: bucketValue,
|
||||
},
|
||||
{
|
||||
key: 'bucket-path',
|
||||
label: t('Bucket API Path'),
|
||||
children: (
|
||||
<Typography.Text copyable={{ text: bucketApiPath }}>
|
||||
<code>{bucketApiPath}</code>
|
||||
</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'region',
|
||||
label: t('Region'),
|
||||
children: regionValue,
|
||||
},
|
||||
{
|
||||
key: 'base-path',
|
||||
label: t('Base Path'),
|
||||
children: basePathValue,
|
||||
},
|
||||
{
|
||||
key: 'access',
|
||||
label: t('Access Key'),
|
||||
children: accessKeyValue ? (
|
||||
<Typography.Text copyable={{ text: accessKeyValue }}>{accessKeyValue}</Typography.Text>
|
||||
) : t('Not set'),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Form
|
||||
form={s3Form}
|
||||
layout="vertical"
|
||||
onFinish={handleSaveS3}
|
||||
disabled={!s3Enabled || loading}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<Form.Item
|
||||
name="bucket"
|
||||
label={t('Bucket Name')}
|
||||
rules={[{ required: true, message: t('Please input bucket name') }]}
|
||||
>
|
||||
<Input disabled={!s3Enabled || loading} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="region"
|
||||
label={t('Region')}
|
||||
rules={[{ required: true, message: t('Please input region') }]}
|
||||
>
|
||||
<Input disabled={!s3Enabled || loading} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="basePath"
|
||||
label={t('Base Path')}
|
||||
tooltip={t('Mount point inside the virtual file system (e.g. / or /workspace).')}
|
||||
>
|
||||
<Input disabled={!s3Enabled || loading} placeholder="/" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="accessKey"
|
||||
label={t('Access Key')}
|
||||
rules={[{ required: true, message: t('Please input access key') }]}
|
||||
>
|
||||
<Input disabled={!s3Enabled || loading} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="secretKey"
|
||||
label={t('Secret Key')}
|
||||
rules={[{ required: true, message: t('Please input secret key') }]}
|
||||
>
|
||||
<Input.Password disabled={!s3Enabled || loading} />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit" loading={s3FormSaving} disabled={!s3Enabled} block>
|
||||
{t('Save S3 Settings')}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
<Typography.Paragraph type="secondary">
|
||||
{t('Example CLI command')}
|
||||
<Typography.Text code style={{ display: 'block', marginTop: 8 }} copyable={{ text: exampleCommand }}>
|
||||
{exampleCommand}
|
||||
</Typography.Text>
|
||||
</Typography.Paragraph>
|
||||
</Space>
|
||||
</Card>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import LayoutShell from './LayoutShell.tsx';
|
||||
import LoginPage from '../pages/LoginPage.tsx';
|
||||
import SetupPage from '../pages/SetupPage.tsx';
|
||||
import PublicSharePage from '../pages/PublicSharePage';
|
||||
import ForgotPasswordPage from '../pages/ForgotPasswordPage';
|
||||
import ResetPasswordPage from '../pages/ResetPasswordPage';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import type { JSX } from 'react';
|
||||
|
||||
@@ -13,12 +15,16 @@ export const routes: RouteObject[] = [
|
||||
{ path: '/login', element: <LoginPage /> },
|
||||
{ path: '/share/:token', element: <PublicSharePage /> },
|
||||
{ path: '/setup', element: <SetupPage /> },
|
||||
{ path: '/forgot-password', element: <ForgotPasswordPage /> },
|
||||
{ path: '/reset-password', element: <ResetPasswordPage /> },
|
||||
];
|
||||
|
||||
function RequireAuth({ children }: { children: JSX.Element }) {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const location = useLocation();
|
||||
if (!isAuthenticated && !location.pathname.startsWith('/share/') && location.pathname !== '/login' && location.pathname !== '/register') {
|
||||
const publicPaths = ['/login', '/register', '/forgot-password', '/reset-password'];
|
||||
const isPublic = publicPaths.some((p) => location.pathname.startsWith(p));
|
||||
if (!isAuthenticated && !location.pathname.startsWith('/share/') && !isPublic) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
return children;
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
.fx-settings-tabs .ant-tabs-nav {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.fx-settings-tabs .ant-tabs-nav-list {
|
||||
padding: 8px 4px;
|
||||
padding: 4px 8px;
|
||||
width: 100%;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.fx-settings-tabs .ant-tabs-tab {
|
||||
margin: 4px 0 !important;
|
||||
margin: 0 !important;
|
||||
border-radius: 8px;
|
||||
padding: 6px 10px !important;
|
||||
padding: 8px 12px !important;
|
||||
flex: 1 1 auto;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.fx-settings-tabs .ant-tabs-tab .ant-tabs-tab-btn {
|
||||
|
||||
Reference in New Issue
Block a user