Compare commits

...

26 Commits

Author SHA1 Message Date
shiyu
cf8d10f71c chore: update version to v1.3.8 2025-11-27 18:07:18 +08:00
shiyu
5c4d3a625b feat: add endpoint to list objects with trailing slash in S3 router 2025-11-26 15:23:44 +08:00
shiyu
f0a51c3369 chore: consolidate .gitignore files 2025-11-20 14:11:55 +08:00
shiyu
3278896d4b feat: normalize adapter types and improve validation in adapters 2025-11-20 12:43:41 +08:00
Copilot
219f3e81b8 feat: add Google Drive storage adapter (#50)
* Initial plan

* Add Google Drive storage adapter implementation

Co-authored-by: DrizzleTime <169802108+DrizzleTime@users.noreply.github.com>

* Add optional methods for direct download and thumbnail support

Co-authored-by: DrizzleTime <169802108+DrizzleTime@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: DrizzleTime <169802108+DrizzleTime@users.noreply.github.com>
2025-11-20 11:49:32 +08:00
shiyu
8ef0a34642 fix: handle missing size and modification time in WebDAV file info 2025-11-15 14:17:54 +08:00
shiyu
8aaa2900ef fix: adjust Flex component styles for consistent width in loading states 2025-11-11 12:05:59 +08:00
shiyu
e3e68f5397 chore: update version to v1.3.7 2025-11-10 11:02:45 +08:00
shiyu
78dfbac458 feat: enhance object retrieval and upload handling in S3 routes 2025-11-10 10:07:25 +08:00
shiyu
583db651a7 feat: add S3 endpoint to Nginx configuration 2025-11-08 22:44:09 +08:00
shiyu
3a15362422 feat: add S3 mapping configuration and API endpoints 2025-11-07 23:06:51 +08:00
shiyu
e55a09d84f feat: add WebDAV mapping configuration and UI in System Settings 2025-11-07 22:32:50 +08:00
shiyu
8957174e6f chore: update version to v1.3.6 2025-11-07 12:06:51 +08:00
shiyu
abb6b0ce22 feat: add workflow to clean dangling Docker images 2025-11-07 11:08:09 +08:00
shiyu
74df438053 style: update grid layout for fx-grid and fx-grid-item for better responsiveness 2025-11-07 09:57:47 +08:00
shiyu
f271a8bee5 feat: add User ID and Raw Log fields to log details in LogsPage 2025-11-06 16:36:35 +08:00
shiyu
17236e601f chore: update version to v1.3.5 2025-11-06 15:54:20 +08:00
shiyu
71e5f84eb7 style: adjust tab spacing and padding in System Settings page 2025-11-06 15:46:40 +08:00
shiyu
4e724b9c4a feat: add password reset functionality with email templates 2025-11-06 15:31:13 +08:00
时雨
ba62bd0d4a chore: add custom sponsorship URL to FUNDING.yml
Updated the funding model to include a custom sponsorship URL.
2025-10-28 17:43:54 +08:00
ShiYu
138296e5a6 feat: add favicon configuration 2025-10-28 11:01:46 +08:00
ShiYu
51326dea08 chore: update version to v1.3.4 2025-10-22 13:36:27 +08:00
ShiYu
ac6d8ff7ad fix: handle create-root request gracefully in write_file_stream 2025-10-22 13:10:19 +08:00
ShiYu
029aa2574d feat: optimize skeleton screen animations 2025-10-22 10:30:19 +08:00
ShiYu
eeb0e6aa70 feat: add issue templates for bug reports, feature requests, and questions 2025-10-21 14:01:54 +08:00
ShiYu
d1ceb7ddba fix: update gravatar URL to use cn.cravatar.com 2025-10-21 12:20:53 +08:00
53 changed files with 3609 additions and 82 deletions

1
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1 @@
custom: https://foxel.cc/sponsor.html

75
.github/ISSUE_TEMPLATE/bug_report.yml vendored Normal file
View 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

View 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
View 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
View 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
View File

@@ -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?

View File

@@ -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)

View File

@@ -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)

View File

@@ -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="密码已重置")

View File

@@ -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
View 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
View 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)

View File

@@ -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())

View File

@@ -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;

View File

@@ -22,4 +22,5 @@ dependencies = [
"uvicorn>=0.37.0",
"pymilvus[milvus-lite]>=2.6.2",
"paramiko>=4.0.0",
"pydantic[email]>=2.11.7",
]

View File

@@ -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
View 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)

View 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)

View File

@@ -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},

View File

@@ -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 复制"},

View File

@@ -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

View File

@@ -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 名称",

View File

@@ -8,7 +8,7 @@ from telethon.sessions import StringSession
import socks
# 适配器类型标识
ADAPTER_TYPE = "Telegram"
ADAPTER_TYPE = "telegram"
# 适配器配置项定义
CONFIG_SCHEMA = [

View File

@@ -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"]:

View File

@@ -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,

View File

@@ -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
View 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},
)

View File

@@ -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}")

View File

@@ -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})

View 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
View 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
View File

@@ -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
View File

@@ -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?

View File

@@ -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);

View File

@@ -21,7 +21,6 @@ export interface AdapterTypeField {
export interface AdapterTypeMeta {
type: string;
name: string;
config_schema: AdapterTypeField[];
}

View File

@@ -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,
});
},
};

View File

@@ -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
View 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 },
});
}

View File

@@ -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); }

View File

@@ -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',

View File

@@ -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': '系统初始化',

View File

@@ -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;

View File

@@ -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>

View 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>
);
}

View File

@@ -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"

View File

@@ -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>

View File

@@ -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>
) : (

View 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>
);
}

View File

@@ -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>

View 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>;
}

View File

@@ -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>
);
}

View File

@@ -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;

View File

@@ -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 {