mirror of
https://github.com/DrizzleTime/Foxel.git
synced 2026-05-11 18:10:10 +08:00
Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e3e68f5397 | ||
|
|
78dfbac458 | ||
|
|
583db651a7 | ||
|
|
3a15362422 | ||
|
|
e55a09d84f | ||
|
|
8957174e6f | ||
|
|
abb6b0ce22 | ||
|
|
74df438053 | ||
|
|
f271a8bee5 | ||
|
|
17236e601f | ||
|
|
71e5f84eb7 | ||
|
|
4e724b9c4a | ||
|
|
ba62bd0d4a | ||
|
|
138296e5a6 | ||
|
|
51326dea08 | ||
|
|
ac6d8ff7ad | ||
|
|
029aa2574d | ||
|
|
eeb0e6aa70 | ||
|
|
d1ceb7ddba | ||
|
|
63b54458e9 | ||
|
|
f7e6815265 | ||
|
|
4d6e0b86ad | ||
|
|
77a4749fec | ||
|
|
8eaa025f7e | ||
|
|
11799cd97c | ||
|
|
c14224827d |
1
.github/FUNDING.yml
vendored
Normal file
1
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
custom: https://foxel.cc/sponsor.html
|
||||||
75
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
75
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
name: Bug Report / 缺陷报告
|
||||||
|
description: Report reproducible defects with clear context / 请提供可复现的缺陷信息
|
||||||
|
title: "[Bug] "
|
||||||
|
labels:
|
||||||
|
- bug
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Thanks for helping us improve Foxel! / 感谢你帮助改进 Foxel!
|
||||||
|
Please confirm the checklist below before filing. / 在提交前请确认以下事项。
|
||||||
|
- type: checkboxes
|
||||||
|
id: validations
|
||||||
|
attributes:
|
||||||
|
label: Pre-flight Check / 提交前检查
|
||||||
|
options:
|
||||||
|
- label: I searched existing issues and docs / 我已搜索现有 Issue 与文档
|
||||||
|
required: true
|
||||||
|
- label: This is not a question or feature request / 这不是问题咨询或功能需求
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: summary
|
||||||
|
attributes:
|
||||||
|
label: Bug Summary / 缺陷摘要
|
||||||
|
description: Briefly describe what is wrong / 简要说明出现了什么问题
|
||||||
|
placeholder: e.g. Upload fails with 500 error / 例如:上传时报 500 错误
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: steps
|
||||||
|
attributes:
|
||||||
|
label: Steps to Reproduce / 复现步骤
|
||||||
|
description: List numbered steps to trigger the bug / 列出触发问题的步骤
|
||||||
|
placeholder: |
|
||||||
|
1. ...
|
||||||
|
2. ...
|
||||||
|
3. ...
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: expected
|
||||||
|
attributes:
|
||||||
|
label: Expected Behavior / 预期行为
|
||||||
|
description: What should happen instead? / 期望看到什么结果?
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: actual
|
||||||
|
attributes:
|
||||||
|
label: Actual Behavior / 实际行为
|
||||||
|
description: What actually happens? Include messages or screenshots / 实际发生了什么?可附报错或截图
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: input
|
||||||
|
id: version
|
||||||
|
attributes:
|
||||||
|
label: Version / 版本信息
|
||||||
|
description: Git commit, tag, or build number / 提供 Git 提交、标签或构建号
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
- type: textarea
|
||||||
|
id: environment
|
||||||
|
attributes:
|
||||||
|
label: Environment / 运行环境
|
||||||
|
description: OS, browser, API server config, etc. / 操作系统、浏览器、服务端配置等
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
- type: textarea
|
||||||
|
id: logs
|
||||||
|
attributes:
|
||||||
|
label: Logs & Attachments / 日志与附件
|
||||||
|
description: Paste relevant logs, stack traces, screenshots / 粘贴相关日志、堆栈或截图
|
||||||
|
render: shell
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
56
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
56
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
name: Feature Request / 功能需求
|
||||||
|
description: Suggest enhancements or new capabilities / 提出改进或新增能力
|
||||||
|
title: "[Feature] "
|
||||||
|
labels:
|
||||||
|
- enhancement
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Tell us about your idea! / 欢迎分享你的想法!
|
||||||
|
Please complete the sections below so we can evaluate it quickly. / 请完整填写以下信息,便于快速评估。
|
||||||
|
- type: checkboxes
|
||||||
|
id: prechecks
|
||||||
|
attributes:
|
||||||
|
label: Pre-flight Check / 提交前检查
|
||||||
|
options:
|
||||||
|
- label: I searched existing issues and roadmap / 我已搜索现有 Issue 与路线图
|
||||||
|
required: true
|
||||||
|
- label: This is not a bug report or question / 这不是缺陷或问题咨询
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: summary
|
||||||
|
attributes:
|
||||||
|
label: Feature Summary / 功能概述
|
||||||
|
description: What do you want to build? / 希望新增什么能力?
|
||||||
|
placeholder: e.g. Support sharing download links / 例如:支持分享下载链接
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: motivation
|
||||||
|
attributes:
|
||||||
|
label: Motivation / 背景与价值
|
||||||
|
description: Why is this feature important? Who benefits? / 为什么重要?受益者是谁?
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: scope
|
||||||
|
attributes:
|
||||||
|
label: Proposed Solution / 建议方案
|
||||||
|
description: Outline how the feature might work, including API or UI hints / 描述可能的实现方式,包含 API 或 UI 提示
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
- type: textarea
|
||||||
|
id: alternatives
|
||||||
|
attributes:
|
||||||
|
label: Alternatives / 可选方案
|
||||||
|
description: List any alternatives considered / 如有考虑过其他方案请列出
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
- type: textarea
|
||||||
|
id: extra
|
||||||
|
attributes:
|
||||||
|
label: Additional Context / 补充信息
|
||||||
|
description: Diagrams, sketches, links, constraints, etc. / 可附上草图、链接或约束
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
42
.github/ISSUE_TEMPLATE/question.yml
vendored
Normal file
42
.github/ISSUE_TEMPLATE/question.yml
vendored
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
name: Question / 问题咨询
|
||||||
|
description: Ask about usage, configuration, or clarification / 用于使用、配置或澄清问题
|
||||||
|
title: "[Question] "
|
||||||
|
labels:
|
||||||
|
- question
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Need help? You're in the right place. / 需要帮助?请按以下提示填写。
|
||||||
|
Check the docs before filing. / 提交前请先查阅文档。
|
||||||
|
- type: checkboxes
|
||||||
|
id: prechecks
|
||||||
|
attributes:
|
||||||
|
label: Pre-flight Check / 提交前检查
|
||||||
|
options:
|
||||||
|
- label: I searched existing issues and discussions / 我已搜索现有 Issue 和讨论
|
||||||
|
required: true
|
||||||
|
- label: I read the relevant documentation / 我已阅读相关文档
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: question
|
||||||
|
attributes:
|
||||||
|
label: Question Details / 问题详情
|
||||||
|
description: What do you need help with? Be specific. / 具体说明需要帮助的内容
|
||||||
|
placeholder: Describe the scenario, expectation, and blockers / 说明场景、期望结果与阻碍
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: tried
|
||||||
|
attributes:
|
||||||
|
label: What You Tried / 已尝试方案
|
||||||
|
description: List commands, configs, or steps attempted / 列出尝试过的命令、配置或步骤
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
- type: textarea
|
||||||
|
id: context
|
||||||
|
attributes:
|
||||||
|
label: Additional Context / 补充信息
|
||||||
|
description: Environment details, logs, screenshots / 可补充运行环境、日志或截图
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
51
.github/workflows/docker-clean.yml
vendored
Normal file
51
.github/workflows/docker-clean.yml
vendored
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
name: Clean dangling Docker images
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
docker-clean:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Delete untagged GHCR versions
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
OWNER="${GITHUB_REPOSITORY_OWNER}"
|
||||||
|
PACKAGE="$(echo "${GITHUB_REPOSITORY##*/}" | tr '[:upper:]' '[:lower:]')"
|
||||||
|
|
||||||
|
OWNER_TYPE="$(gh api "/users/${OWNER}" -q '.type')"
|
||||||
|
if [[ "${OWNER_TYPE}" == "Organization" ]]; then
|
||||||
|
SCOPE="orgs/${OWNER}"
|
||||||
|
else
|
||||||
|
SCOPE="users/${OWNER}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
BASE_PATH="/${SCOPE}/packages/container/${PACKAGE}"
|
||||||
|
|
||||||
|
if ! gh api "${BASE_PATH}" >/dev/null 2>&1; then
|
||||||
|
echo "Package ghcr.io/${OWNER}/${PACKAGE} not found or accessible. Nothing to clean."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
mapfile -t VERSION_IDS < <(gh api --paginate "${BASE_PATH}/versions?per_page=100" \
|
||||||
|
-q '.[] | select(.metadata.container.tags | length == 0) | .id')
|
||||||
|
|
||||||
|
if [[ ${#VERSION_IDS[@]} -eq 0 ]]; then
|
||||||
|
echo "No untagged versions to delete."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Deleting ${#VERSION_IDS[@]} untagged versions from ghcr.io/${OWNER}/${PACKAGE}..."
|
||||||
|
for id in "${VERSION_IDS[@]}"; do
|
||||||
|
gh api -X DELETE "${BASE_PATH}/versions/${id}" >/dev/null
|
||||||
|
echo "Deleted version ${id}"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Cleanup complete."
|
||||||
4
.github/workflows/docker.yml
vendored
4
.github/workflows/docker.yml
vendored
@@ -2,6 +2,8 @@ name: Build and Push Docker image
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
tags:
|
tags:
|
||||||
- 'v*.*.*'
|
- 'v*.*.*'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
@@ -48,4 +50,4 @@ jobs:
|
|||||||
context: .
|
context: .
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
push: true
|
push: true
|
||||||
tags: ${{ env.DOCKER_TAGS }}
|
tags: ${{ env.DOCKER_TAGS }}
|
||||||
|
|||||||
@@ -13,7 +13,9 @@ FROM python:3.13-slim
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y nginx git && rm -rf /var/lib/apt/lists/*
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends nginx git ffmpeg \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
RUN pip install uv
|
RUN pip install uv
|
||||||
COPY pyproject.toml uv.lock ./
|
COPY pyproject.toml uv.lock ./
|
||||||
@@ -35,4 +37,4 @@ EXPOSE 80
|
|||||||
COPY entrypoint.sh /entrypoint.sh
|
COPY entrypoint.sh /entrypoint.sh
|
||||||
RUN chmod +x /entrypoint.sh
|
RUN chmod +x /entrypoint.sh
|
||||||
|
|
||||||
CMD ["/entrypoint.sh"]
|
CMD ["/entrypoint.sh"]
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from fastapi import FastAPI
|
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 adapters, virtual_fs, auth, config, processors, tasks, logs, share, backup, search, vector_db, offline_downloads, ai_providers, email
|
||||||
from .routes import webdav
|
from .routes import webdav, s3
|
||||||
from .routes import plugins
|
from .routes import plugins
|
||||||
|
|
||||||
|
|
||||||
@@ -21,4 +21,6 @@ def include_routers(app: FastAPI):
|
|||||||
app.include_router(ai_providers.router)
|
app.include_router(ai_providers.router)
|
||||||
app.include_router(plugins.router)
|
app.include_router(plugins.router)
|
||||||
app.include_router(webdav.router)
|
app.include_router(webdav.router)
|
||||||
|
app.include_router(s3.router)
|
||||||
app.include_router(offline_downloads.router)
|
app.include_router(offline_downloads.router)
|
||||||
|
app.include_router(email.router)
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ from services.auth import (
|
|||||||
Token,
|
Token,
|
||||||
get_current_active_user,
|
get_current_active_user,
|
||||||
User,
|
User,
|
||||||
|
request_password_reset,
|
||||||
|
verify_password_reset_token,
|
||||||
|
reset_password_with_token,
|
||||||
)
|
)
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from datetime import timedelta
|
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()
|
email = (current_user.email or "").strip().lower()
|
||||||
md5_hash = hashlib.md5(email.encode("utf-8")).hexdigest()
|
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({
|
return success({
|
||||||
"id": current_user.id,
|
"id": current_user.id,
|
||||||
"username": current_user.username,
|
"username": current_user.username,
|
||||||
@@ -83,6 +86,15 @@ class UpdateMeRequest(BaseModel):
|
|||||||
new_password: str | None = None
|
new_password: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class PasswordResetRequest(BaseModel):
|
||||||
|
email: str
|
||||||
|
|
||||||
|
|
||||||
|
class PasswordResetConfirm(BaseModel):
|
||||||
|
token: str
|
||||||
|
password: str
|
||||||
|
|
||||||
|
|
||||||
@router.put("/me", summary="更新当前登录用户信息")
|
@router.put("/me", summary="更新当前登录用户信息")
|
||||||
async def update_me(
|
async def update_me(
|
||||||
payload: UpdateMeRequest,
|
payload: UpdateMeRequest,
|
||||||
@@ -120,3 +132,24 @@ async def update_me(
|
|||||||
"full_name": db_user.full_name,
|
"full_name": db_user.full_name,
|
||||||
"gravatar_url": gravatar_url,
|
"gravatar_url": gravatar_url,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/password-reset/request", summary="请求密码重置邮件")
|
||||||
|
async def password_reset_request_endpoint(payload: PasswordResetRequest):
|
||||||
|
await request_password_reset(payload.email)
|
||||||
|
return success(msg="如果邮箱存在,将发送重置邮件")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/password-reset/verify", summary="校验密码重置令牌")
|
||||||
|
async def password_reset_verify(token: str):
|
||||||
|
user = await verify_password_reset_token(token)
|
||||||
|
return success({
|
||||||
|
"username": user.username,
|
||||||
|
"email": user.email,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/password-reset/confirm", summary="使用令牌重置密码")
|
||||||
|
async def password_reset_confirm(payload: PasswordResetConfirm):
|
||||||
|
await reset_password_with_token(payload.token, payload.password)
|
||||||
|
return success(msg="密码已重置")
|
||||||
|
|||||||
@@ -37,10 +37,13 @@ async def get_all_config(
|
|||||||
|
|
||||||
@router.get("/status")
|
@router.get("/status")
|
||||||
async def get_system_status():
|
async def get_system_status():
|
||||||
|
logo = await ConfigCenter.get("APP_LOGO", "/logo.svg")
|
||||||
|
favicon = await ConfigCenter.get("APP_FAVICON", logo)
|
||||||
system_info = {
|
system_info = {
|
||||||
"version": VERSION,
|
"version": VERSION,
|
||||||
"title": await ConfigCenter.get("APP_NAME", "Foxel"),
|
"title": await ConfigCenter.get("APP_NAME", "Foxel"),
|
||||||
"logo": await ConfigCenter.get("APP_LOGO", "/logo.svg"),
|
"logo": logo,
|
||||||
|
"favicon": favicon,
|
||||||
"is_initialized": await has_users(),
|
"is_initialized": await has_users(),
|
||||||
"app_domain": await ConfigCenter.get("APP_DOMAIN"),
|
"app_domain": await ConfigCenter.get("APP_DOMAIN"),
|
||||||
"file_domain": await ConfigCenter.get("FILE_DOMAIN"),
|
"file_domain": await ConfigCenter.get("FILE_DOMAIN"),
|
||||||
|
|||||||
92
api/routes/email.py
Normal file
92
api/routes/email.py
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
|
||||||
|
from services.auth import User, get_current_active_user
|
||||||
|
from services.email import EmailService, EmailTemplateRenderer
|
||||||
|
from schemas.email import EmailTestRequest, EmailTemplateUpdate, EmailTemplatePreviewPayload
|
||||||
|
from api.response import success
|
||||||
|
from services.logging import LogService
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(
|
||||||
|
prefix="/api/email",
|
||||||
|
tags=["email"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/test")
|
||||||
|
async def trigger_test_email(
|
||||||
|
payload: EmailTestRequest,
|
||||||
|
current_user: User = Depends(get_current_active_user),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
task = await EmailService.enqueue_email(
|
||||||
|
recipients=[str(payload.to)],
|
||||||
|
subject=payload.subject,
|
||||||
|
template=payload.template,
|
||||||
|
context=payload.context,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc))
|
||||||
|
await LogService.action(
|
||||||
|
"route:email",
|
||||||
|
"Triggered email test",
|
||||||
|
details={"task_id": task.id, "template": payload.template, "to": str(payload.to)},
|
||||||
|
user_id=getattr(current_user, "id", None),
|
||||||
|
)
|
||||||
|
return success({"task_id": task.id})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/templates")
|
||||||
|
async def list_email_templates(
|
||||||
|
current_user: User = Depends(get_current_active_user),
|
||||||
|
):
|
||||||
|
templates = await EmailTemplateRenderer.list_templates()
|
||||||
|
return success({"templates": templates})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/templates/{name}")
|
||||||
|
async def get_email_template(
|
||||||
|
name: str,
|
||||||
|
current_user: User = Depends(get_current_active_user),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
content = await EmailTemplateRenderer.load(name)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc))
|
||||||
|
except FileNotFoundError:
|
||||||
|
raise HTTPException(status_code=404, detail="模板不存在")
|
||||||
|
return success({"name": name, "content": content})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/templates/{name}")
|
||||||
|
async def update_email_template(
|
||||||
|
name: str,
|
||||||
|
payload: EmailTemplateUpdate,
|
||||||
|
current_user: User = Depends(get_current_active_user),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
await EmailTemplateRenderer.save(name, payload.content)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc))
|
||||||
|
await LogService.action(
|
||||||
|
"route:email",
|
||||||
|
"Updated email template",
|
||||||
|
details={"template": name},
|
||||||
|
user_id=getattr(current_user, "id", None),
|
||||||
|
)
|
||||||
|
return success({"name": name})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/templates/{name}/preview")
|
||||||
|
async def preview_email_template(
|
||||||
|
name: str,
|
||||||
|
payload: EmailTemplatePreviewPayload,
|
||||||
|
current_user: User = Depends(get_current_active_user),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
html = await EmailTemplateRenderer.render(name, payload.context)
|
||||||
|
except FileNotFoundError:
|
||||||
|
raise HTTPException(status_code=404, detail="模板不存在")
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc))
|
||||||
|
return success({"html": html})
|
||||||
538
api/routes/s3.py
Normal file
538
api/routes/s3.py
Normal file
@@ -0,0 +1,538 @@
|
|||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
@@ -17,7 +17,7 @@ from services.virtual_fs import (
|
|||||||
verify_temp_link_token,
|
verify_temp_link_token,
|
||||||
maybe_redirect_download,
|
maybe_redirect_download,
|
||||||
)
|
)
|
||||||
from services.thumbnail import is_image_filename, get_or_create_thumb, is_raw_filename
|
from services.thumbnail import is_image_filename, get_or_create_thumb, is_raw_filename, is_video_filename
|
||||||
from schemas import MkdirRequest, MoveRequest
|
from schemas import MkdirRequest, MoveRequest
|
||||||
from api.response import success
|
from api.response import success
|
||||||
from services.config import ConfigCenter
|
from services.config import ConfigCenter
|
||||||
@@ -121,8 +121,8 @@ async def get_thumb(
|
|||||||
adapter, mount, root, rel = await resolve_adapter_and_rel(full_path)
|
adapter, mount, root, rel = await resolve_adapter_and_rel(full_path)
|
||||||
if not rel or rel.endswith('/'):
|
if not rel or rel.endswith('/'):
|
||||||
raise HTTPException(400, detail="Not a file")
|
raise HTTPException(400, detail="Not a file")
|
||||||
if not is_image_filename(rel):
|
if not (is_image_filename(rel) or is_video_filename(rel)):
|
||||||
raise HTTPException(404, detail="Not an image")
|
raise HTTPException(404, detail="Not an image or video")
|
||||||
# type: ignore
|
# type: ignore
|
||||||
data, mime, key = await get_or_create_thumb(adapter, mount.id, root, rel, w, h, fit)
|
data, mime, key = await get_or_create_thumb(adapter, mount.id, root, rel, w, h, fit)
|
||||||
headers = {
|
headers = {
|
||||||
|
|||||||
@@ -20,6 +20,16 @@ from services.virtual_fs import (
|
|||||||
copy_path,
|
copy_path,
|
||||||
stream_file,
|
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"])
|
router = APIRouter(prefix="/webdav", tags=["webdav"])
|
||||||
@@ -140,12 +150,17 @@ def _normalize_fs_path(path: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
@router.options("/{path:path}")
|
@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())
|
return Response(status_code=200, headers=_dav_headers())
|
||||||
|
|
||||||
|
|
||||||
@router.api_route("/{path:path}", methods=["PROPFIND"])
|
@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)
|
full_path = _normalize_fs_path(path)
|
||||||
depth = request.headers.get("Depth", "1").lower()
|
depth = request.headers.get("Depth", "1").lower()
|
||||||
if depth not in ("0", "1", "infinity"):
|
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}")
|
@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)
|
full_path = _normalize_fs_path(path)
|
||||||
range_header = request.headers.get("Range")
|
range_header = request.headers.get("Range")
|
||||||
return await stream_file(full_path, range_header)
|
return await stream_file(full_path, range_header)
|
||||||
|
|
||||||
|
|
||||||
@router.head("/{path:path}")
|
@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)
|
full_path = _normalize_fs_path(path)
|
||||||
try:
|
try:
|
||||||
st = await stat_file(full_path)
|
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"])
|
@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)
|
full_path = _normalize_fs_path(path)
|
||||||
async def body_iter():
|
async def body_iter():
|
||||||
async for chunk in request.stream():
|
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"])
|
@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)
|
full_path = _normalize_fs_path(path)
|
||||||
await delete_path(full_path)
|
await delete_path(full_path)
|
||||||
return Response(status_code=204, headers=_dav_headers())
|
return Response(status_code=204, headers=_dav_headers())
|
||||||
|
|
||||||
|
|
||||||
@router.api_route("/{path:path}", methods=["MKCOL"])
|
@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)
|
full_path = _normalize_fs_path(path)
|
||||||
await make_dir(full_path)
|
await make_dir(full_path)
|
||||||
return Response(status_code=201, headers=_dav_headers())
|
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"
|
overwrite = request.headers.get("Overwrite", "T").upper() != "F"
|
||||||
await copy_path(full_src, dst, overwrite=overwrite)
|
await copy_path(full_src, dst, overwrite=overwrite)
|
||||||
return Response(status_code=201 if not overwrite else 204, headers=_dav_headers())
|
return Response(status_code=201 if not overwrite else 204, headers=_dav_headers())
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ http {
|
|||||||
listen 80;
|
listen 80;
|
||||||
server_name _;
|
server_name _;
|
||||||
|
|
||||||
location ~ ^/(api|webdav|docs|openapi\.json$) {
|
location ~ ^/(api|webdav|s3|docs|openapi\.json$) {
|
||||||
proxy_pass http://127.0.0.1:8000;
|
proxy_pass http://127.0.0.1:8000;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
|||||||
@@ -22,4 +22,5 @@ dependencies = [
|
|||||||
"uvicorn>=0.37.0",
|
"uvicorn>=0.37.0",
|
||||||
"pymilvus[milvus-lite]>=2.6.2",
|
"pymilvus[milvus-lite]>=2.6.2",
|
||||||
"paramiko>=4.0.0",
|
"paramiko>=4.0.0",
|
||||||
|
"pydantic[email]>=2.11.7",
|
||||||
]
|
]
|
||||||
|
|||||||
18
schemas/email.py
Normal file
18
schemas/email.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
from typing import Any, Dict
|
||||||
|
|
||||||
|
from pydantic import BaseModel, EmailStr, Field
|
||||||
|
|
||||||
|
|
||||||
|
class EmailTestRequest(BaseModel):
|
||||||
|
to: EmailStr
|
||||||
|
subject: str = Field(..., min_length=1)
|
||||||
|
template: str = Field(default="test", min_length=1)
|
||||||
|
context: Dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class EmailTemplateUpdate(BaseModel):
|
||||||
|
content: str
|
||||||
|
|
||||||
|
|
||||||
|
class EmailTemplatePreviewPayload(BaseModel):
|
||||||
|
context: Dict[str, Any] = Field(default_factory=dict)
|
||||||
@@ -8,7 +8,7 @@ class VfsEntry(BaseModel):
|
|||||||
size: int
|
size: int
|
||||||
mtime: int
|
mtime: int
|
||||||
type: Optional[str] = None
|
type: Optional[str] = None
|
||||||
is_image: Optional[bool] = None
|
has_thumbnail: Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
class DirListing(BaseModel):
|
class DirListing(BaseModel):
|
||||||
|
|||||||
160
services/auth.py
160
services/auth.py
@@ -1,5 +1,8 @@
|
|||||||
|
import asyncio
|
||||||
|
from dataclasses import dataclass
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
import secrets
|
||||||
|
|
||||||
import jwt
|
import jwt
|
||||||
from fastapi import Depends, HTTPException, status
|
from fastapi import Depends, HTTPException, status
|
||||||
@@ -10,9 +13,78 @@ from pydantic import BaseModel
|
|||||||
|
|
||||||
from models.database import UserAccount
|
from models.database import UserAccount
|
||||||
from services.config import ConfigCenter
|
from services.config import ConfigCenter
|
||||||
|
from services.logging import LogService
|
||||||
|
|
||||||
ALGORITHM = "HS256"
|
ALGORITHM = "HS256"
|
||||||
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 365
|
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():
|
async def get_secret_key():
|
||||||
@@ -132,6 +204,94 @@ async def create_access_token(data: dict, expires_delta: timedelta | None = None
|
|||||||
return encoded_jwt
|
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)]):
|
async def get_current_user(token: Annotated[str, Depends(oauth2_scheme)]):
|
||||||
credentials_exception = HTTPException(
|
credentials_exception = HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from typing import Any, Optional, Dict
|
|||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
from models.database import Configuration
|
from models.database import Configuration
|
||||||
load_dotenv(dotenv_path=".env")
|
load_dotenv(dotenv_path=".env")
|
||||||
VERSION = "v1.3.2"
|
VERSION = "v1.3.7"
|
||||||
|
|
||||||
class ConfigCenter:
|
class ConfigCenter:
|
||||||
_cache: Dict[str, Any] = {}
|
_cache: Dict[str, Any] = {}
|
||||||
|
|||||||
201
services/email.py
Normal file
201
services/email.py
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import smtplib
|
||||||
|
from email.message import EmailMessage
|
||||||
|
from email.utils import formataddr
|
||||||
|
from enum import Enum
|
||||||
|
from pathlib import Path
|
||||||
|
from string import Template
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, EmailStr, Field, ValidationError
|
||||||
|
|
||||||
|
from services.config import ConfigCenter
|
||||||
|
from services.logging import LogService
|
||||||
|
|
||||||
|
|
||||||
|
class EmailSecurity(str, Enum):
|
||||||
|
NONE = "none"
|
||||||
|
SSL = "ssl"
|
||||||
|
STARTTLS = "starttls"
|
||||||
|
|
||||||
|
|
||||||
|
class EmailConfig(BaseModel):
|
||||||
|
host: str
|
||||||
|
port: int = Field(..., gt=0)
|
||||||
|
username: Optional[str] = None
|
||||||
|
password: Optional[str] = None
|
||||||
|
sender_email: EmailStr
|
||||||
|
sender_name: Optional[str] = None
|
||||||
|
security: EmailSecurity = EmailSecurity.NONE
|
||||||
|
timeout: float = Field(default=30.0, gt=0.0)
|
||||||
|
|
||||||
|
|
||||||
|
class EmailSendPayload(BaseModel):
|
||||||
|
recipients: List[EmailStr] = Field(..., min_length=1)
|
||||||
|
subject: str = Field(..., min_length=1)
|
||||||
|
template: str = Field(..., min_length=1)
|
||||||
|
context: Dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class EmailTemplateRenderer:
|
||||||
|
ROOT = Path("templates/email")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _resolve_path(cls, template_name: str) -> Path:
|
||||||
|
if not re.fullmatch(r"[A-Za-z0-9_\-]+", template_name):
|
||||||
|
raise ValueError("Invalid template name")
|
||||||
|
return cls.ROOT / f"{template_name}.html"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def list_templates(cls) -> list[str]:
|
||||||
|
cls.ROOT.mkdir(parents=True, exist_ok=True)
|
||||||
|
return sorted(
|
||||||
|
path.stem
|
||||||
|
for path in cls.ROOT.glob("*.html")
|
||||||
|
if path.is_file()
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def load(cls, template_name: str) -> str:
|
||||||
|
path = cls._resolve_path(template_name)
|
||||||
|
if not path.is_file():
|
||||||
|
raise FileNotFoundError(f"Email template '{template_name}' not found")
|
||||||
|
return await asyncio.to_thread(path.read_text, encoding="utf-8")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def save(cls, template_name: str, content: str) -> None:
|
||||||
|
path = cls._resolve_path(template_name)
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
await asyncio.to_thread(path.write_text, content, encoding="utf-8")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def render(cls, template_name: str, context: Dict[str, Any]) -> str:
|
||||||
|
raw = await cls.load(template_name)
|
||||||
|
context = {k: str(v) for k, v in (context or {}).items()}
|
||||||
|
return Template(raw).safe_substitute(context)
|
||||||
|
|
||||||
|
|
||||||
|
class EmailService:
|
||||||
|
CONFIG_KEY = "EMAIL_CONFIG"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def _load_config(cls) -> EmailConfig:
|
||||||
|
raw_config = await ConfigCenter.get(cls.CONFIG_KEY)
|
||||||
|
if raw_config is None:
|
||||||
|
raise ValueError("Email configuration not found")
|
||||||
|
|
||||||
|
if isinstance(raw_config, str):
|
||||||
|
raw_config = raw_config.strip()
|
||||||
|
data: Any = json.loads(raw_config) if raw_config else {}
|
||||||
|
elif isinstance(raw_config, dict):
|
||||||
|
data = raw_config
|
||||||
|
else:
|
||||||
|
raise ValueError("Invalid email configuration format")
|
||||||
|
|
||||||
|
try:
|
||||||
|
return EmailConfig(**data)
|
||||||
|
except ValidationError as exc:
|
||||||
|
raise ValueError(f"Invalid email configuration: {exc}") from exc
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _html_to_text(html: str) -> str:
|
||||||
|
stripped = re.sub(r"<[^>]+>", " ", html)
|
||||||
|
return " ".join(stripped.split())
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def _deliver(cls, config: EmailConfig, payload: EmailSendPayload, html_body: str):
|
||||||
|
message = EmailMessage()
|
||||||
|
message["Subject"] = payload.subject
|
||||||
|
message["From"] = formataddr((config.sender_name or str(config.sender_email), str(config.sender_email)))
|
||||||
|
message["To"] = ", ".join([str(addr) for addr in payload.recipients])
|
||||||
|
|
||||||
|
plain_body = cls._html_to_text(html_body)
|
||||||
|
message.set_content(plain_body or html_body)
|
||||||
|
message.add_alternative(html_body, subtype="html")
|
||||||
|
|
||||||
|
await asyncio.to_thread(cls._deliver_sync, config, message)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _deliver_sync(config: EmailConfig, message: EmailMessage):
|
||||||
|
if config.security == EmailSecurity.SSL:
|
||||||
|
smtp: smtplib.SMTP = smtplib.SMTP_SSL(config.host, config.port, timeout=config.timeout)
|
||||||
|
else:
|
||||||
|
smtp = smtplib.SMTP(config.host, config.port, timeout=config.timeout)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if config.security == EmailSecurity.STARTTLS:
|
||||||
|
smtp.starttls()
|
||||||
|
if config.username and config.password:
|
||||||
|
smtp.login(config.username, config.password)
|
||||||
|
smtp.send_message(message)
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
smtp.quit()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def enqueue_email(
|
||||||
|
cls,
|
||||||
|
recipients: List[str],
|
||||||
|
subject: str,
|
||||||
|
template: str,
|
||||||
|
context: Optional[Dict[str, Any]] = None,
|
||||||
|
):
|
||||||
|
from services.task_queue import TaskProgress, task_queue_service
|
||||||
|
|
||||||
|
payload = EmailSendPayload(
|
||||||
|
recipients=recipients,
|
||||||
|
subject=subject,
|
||||||
|
template=template,
|
||||||
|
context=context or {},
|
||||||
|
)
|
||||||
|
|
||||||
|
task = await task_queue_service.add_task(
|
||||||
|
"send_email",
|
||||||
|
payload.model_dump(mode="json"),
|
||||||
|
)
|
||||||
|
|
||||||
|
await task_queue_service.update_progress(
|
||||||
|
task.id,
|
||||||
|
TaskProgress(stage="queued", percent=0.0, detail="Waiting to send"),
|
||||||
|
)
|
||||||
|
await LogService.action(
|
||||||
|
"email_service",
|
||||||
|
"Email task enqueued",
|
||||||
|
details={"task_id": task.id, "subject": subject, "template": template},
|
||||||
|
)
|
||||||
|
return task
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def send_from_task(cls, task_id: str, data: Dict[str, Any]):
|
||||||
|
from services.task_queue import TaskProgress, task_queue_service
|
||||||
|
|
||||||
|
payload = EmailSendPayload(**data)
|
||||||
|
|
||||||
|
await task_queue_service.update_progress(
|
||||||
|
task_id,
|
||||||
|
TaskProgress(stage="preparing", percent=10.0, detail="Rendering template"),
|
||||||
|
)
|
||||||
|
|
||||||
|
config = await cls._load_config()
|
||||||
|
html_body = await EmailTemplateRenderer.render(payload.template, payload.context)
|
||||||
|
|
||||||
|
await task_queue_service.update_progress(
|
||||||
|
task_id,
|
||||||
|
TaskProgress(stage="sending", percent=60.0, detail="Sending message"),
|
||||||
|
)
|
||||||
|
|
||||||
|
await cls._deliver(config, payload, html_body)
|
||||||
|
|
||||||
|
await task_queue_service.update_progress(
|
||||||
|
task_id,
|
||||||
|
TaskProgress(stage="completed", percent=100.0, detail="Email sent"),
|
||||||
|
)
|
||||||
|
await LogService.info(
|
||||||
|
"email_service",
|
||||||
|
"Email sent",
|
||||||
|
details={"task_id": task_id, "subject": payload.subject},
|
||||||
|
)
|
||||||
@@ -130,6 +130,10 @@ class TaskQueueService:
|
|||||||
|
|
||||||
result = await run_cross_mount_transfer_task(task)
|
result = await run_cross_mount_transfer_task(task)
|
||||||
task.result = result
|
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:
|
else:
|
||||||
raise ValueError(f"Unknown task name: {task.name}")
|
raise ValueError(f"Unknown task name: {task.name}")
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
import asyncio
|
||||||
|
import inspect
|
||||||
import io
|
import io
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import tempfile
|
||||||
|
from contextlib import suppress
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Tuple
|
from typing import Tuple
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
@@ -8,7 +12,10 @@ from fastapi import HTTPException
|
|||||||
ALLOWED_EXT = {"jpg", "jpeg", "png", "webp", "gif", "bmp",
|
ALLOWED_EXT = {"jpg", "jpeg", "png", "webp", "gif", "bmp",
|
||||||
"tiff", "arw", "cr2", "cr3", "nef", "rw2", "orf", "pef", "dng"}
|
"tiff", "arw", "cr2", "cr3", "nef", "rw2", "orf", "pef", "dng"}
|
||||||
RAW_EXT = {"arw", "cr2", "cr3", "nef", "rw2", "orf", "pef", "dng"}
|
RAW_EXT = {"arw", "cr2", "cr3", "nef", "rw2", "orf", "pef", "dng"}
|
||||||
MAX_SOURCE_SIZE = 200 * 1024 * 1024
|
VIDEO_EXT = {"mp4", "mov", "m4v", "avi", "mkv", "wmv", "flv", "webm", "mpg", "mpeg", "3gp"}
|
||||||
|
MAX_IMAGE_SOURCE_SIZE = 200 * 1024 * 1024
|
||||||
|
VIDEO_RANGE_LIMIT = 16 * 1024 * 1024 # 16MB
|
||||||
|
VIDEO_INITIAL_CHUNK = 4 * 1024 * 1024
|
||||||
CACHE_ROOT = Path('data/.thumb_cache')
|
CACHE_ROOT = Path('data/.thumb_cache')
|
||||||
|
|
||||||
|
|
||||||
@@ -26,6 +33,13 @@ def is_raw_filename(name: str) -> bool:
|
|||||||
return parts[1].lower() in RAW_EXT
|
return parts[1].lower() in RAW_EXT
|
||||||
|
|
||||||
|
|
||||||
|
def is_video_filename(name: str) -> bool:
|
||||||
|
parts = name.rsplit('.', 1)
|
||||||
|
if len(parts) < 2:
|
||||||
|
return False
|
||||||
|
return parts[1].lower() in VIDEO_EXT
|
||||||
|
|
||||||
|
|
||||||
def _cache_key(adapter_id: int, rel: str, size: int, mtime: int, w: int, h: int, fit: str) -> str:
|
def _cache_key(adapter_id: int, rel: str, size: int, mtime: int, w: int, h: int, fit: str) -> str:
|
||||||
raw = f"{adapter_id}|{rel}|{size}|{mtime}|{w}x{h}|{fit}".encode()
|
raw = f"{adapter_id}|{rel}|{size}|{mtime}|{w}x{h}|{fit}".encode()
|
||||||
return hashlib.sha1(raw).hexdigest()
|
return hashlib.sha1(raw).hexdigest()
|
||||||
@@ -40,6 +54,30 @@ def _ensure_cache_dir(p: Path):
|
|||||||
p.parent.mkdir(parents=True, exist_ok=True)
|
p.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _image_to_webp(im, w: int, h: int, fit: str) -> Tuple[bytes, str]:
|
||||||
|
from PIL import Image
|
||||||
|
if im.mode not in ("RGB", "RGBA"):
|
||||||
|
im = im.convert("RGBA" if im.mode in ("P", "LA") else "RGB")
|
||||||
|
if fit == 'cover':
|
||||||
|
im_ratio = im.width / im.height
|
||||||
|
target_ratio = w / h
|
||||||
|
if im_ratio > target_ratio:
|
||||||
|
new_h = h
|
||||||
|
new_w = int(h * im_ratio)
|
||||||
|
else:
|
||||||
|
new_w = w
|
||||||
|
new_h = int(w / im_ratio)
|
||||||
|
im = im.resize((new_w, new_h))
|
||||||
|
left = max(0, (im.width - w)//2)
|
||||||
|
top = max(0, (im.height - h)//2)
|
||||||
|
im = im.crop((left, top, left + w, top + h))
|
||||||
|
else:
|
||||||
|
im.thumbnail((w, h))
|
||||||
|
buf = io.BytesIO()
|
||||||
|
im.save(buf, 'WEBP', quality=80)
|
||||||
|
return buf.getvalue(), 'image/webp'
|
||||||
|
|
||||||
|
|
||||||
def generate_thumb(data: bytes, w: int, h: int, fit: str, is_raw: bool = False) -> Tuple[bytes, str]:
|
def generate_thumb(data: bytes, w: int, h: int, fit: str, is_raw: bool = False) -> Tuple[bytes, str]:
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
if is_raw:
|
if is_raw:
|
||||||
@@ -64,35 +102,172 @@ def generate_thumb(data: bytes, w: int, h: int, fit: str, is_raw: bool = False)
|
|||||||
else:
|
else:
|
||||||
im = Image.open(io.BytesIO(data))
|
im = Image.open(io.BytesIO(data))
|
||||||
|
|
||||||
if im.mode not in ("RGB", "RGBA"):
|
return _image_to_webp(im, w, h, fit)
|
||||||
im = im.convert("RGBA" if im.mode in ("P", "LA") else "RGB")
|
|
||||||
if fit == 'cover':
|
|
||||||
im_ratio = im.width / im.height
|
async def _collect_response_bytes(response, limit: int) -> bytes:
|
||||||
target_ratio = w / h
|
if response is None:
|
||||||
if im_ratio > target_ratio:
|
return b""
|
||||||
new_h = h
|
|
||||||
new_w = int(h * im_ratio)
|
try:
|
||||||
else:
|
if isinstance(response, (bytes, bytearray)):
|
||||||
new_w = w
|
return bytes(response[:limit])
|
||||||
new_h = int(w / im_ratio)
|
|
||||||
im = im.resize((new_w, new_h))
|
body = getattr(response, "body", None)
|
||||||
left = max(0, (im.width - w)//2)
|
if body is not None:
|
||||||
top = max(0, (im.height - h)//2)
|
return bytes(body[:limit])
|
||||||
im = im.crop((left, top, left + w, top + h))
|
|
||||||
else:
|
iterator = getattr(response, "body_iterator", None)
|
||||||
im.thumbnail((w, h))
|
if iterator is not None:
|
||||||
buf = io.BytesIO()
|
data = bytearray()
|
||||||
im.save(buf, 'WEBP', quality=80)
|
async for chunk in iterator:
|
||||||
return buf.getvalue(), 'image/webp'
|
if not chunk:
|
||||||
|
continue
|
||||||
|
need = limit - len(data)
|
||||||
|
if need <= 0:
|
||||||
|
break
|
||||||
|
data.extend(chunk[:need])
|
||||||
|
if len(data) >= limit:
|
||||||
|
break
|
||||||
|
return bytes(data)
|
||||||
|
|
||||||
|
if hasattr(response, "__aiter__"):
|
||||||
|
data = bytearray()
|
||||||
|
async for chunk in response:
|
||||||
|
if not chunk:
|
||||||
|
continue
|
||||||
|
need = limit - len(data)
|
||||||
|
if need <= 0:
|
||||||
|
break
|
||||||
|
data.extend(chunk[:need])
|
||||||
|
if len(data) >= limit:
|
||||||
|
break
|
||||||
|
return bytes(data)
|
||||||
|
finally:
|
||||||
|
close_func = getattr(response, "close", None)
|
||||||
|
if callable(close_func):
|
||||||
|
result = close_func()
|
||||||
|
if inspect.isawaitable(result):
|
||||||
|
await result
|
||||||
|
|
||||||
|
return b""
|
||||||
|
|
||||||
|
|
||||||
|
async def _read_range_slice(adapter, root: str, rel: str, start: int, end: int) -> bytes:
|
||||||
|
read_range = getattr(adapter, "read_file_range", None)
|
||||||
|
if callable(read_range):
|
||||||
|
try:
|
||||||
|
return await read_range(root, rel, start, end)
|
||||||
|
except TypeError:
|
||||||
|
return await read_range(root, rel, start, end=end)
|
||||||
|
|
||||||
|
stream_impl = getattr(adapter, "stream_file", None)
|
||||||
|
if callable(stream_impl):
|
||||||
|
range_header = f"bytes={start}-{end}"
|
||||||
|
response = await stream_impl(root, rel, range_header)
|
||||||
|
expected = end - start + 1
|
||||||
|
return await _collect_response_bytes(response, expected)
|
||||||
|
|
||||||
|
read_file = getattr(adapter, "read_file", None)
|
||||||
|
if callable(read_file) and start == 0:
|
||||||
|
data = await read_file(root, rel)
|
||||||
|
slice_end = end + 1
|
||||||
|
return data[:slice_end]
|
||||||
|
|
||||||
|
return b""
|
||||||
|
|
||||||
|
|
||||||
|
async def _read_video_prefix(adapter, root: str, rel: str, size: int, limit: int = VIDEO_RANGE_LIMIT) -> bytes:
|
||||||
|
chunk_size = min(VIDEO_INITIAL_CHUNK, limit)
|
||||||
|
offset = 0
|
||||||
|
collected = bytearray()
|
||||||
|
|
||||||
|
while len(collected) < limit:
|
||||||
|
end = offset + chunk_size - 1
|
||||||
|
data = await _read_range_slice(adapter, root, rel, offset, end)
|
||||||
|
if not data:
|
||||||
|
break
|
||||||
|
collected.extend(data)
|
||||||
|
if len(data) < chunk_size:
|
||||||
|
break
|
||||||
|
offset += len(data)
|
||||||
|
remaining = limit - len(collected)
|
||||||
|
if remaining <= 0:
|
||||||
|
break
|
||||||
|
chunk_size = min(chunk_size * 2, remaining)
|
||||||
|
|
||||||
|
if not collected and size <= limit:
|
||||||
|
read_file = getattr(adapter, "read_file", None)
|
||||||
|
if callable(read_file):
|
||||||
|
blob = await read_file(root, rel)
|
||||||
|
if blob:
|
||||||
|
return bytes(blob[:limit])
|
||||||
|
|
||||||
|
return bytes(collected[:limit])
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_ffmpeg_extract_frame(src_path: str, dst_path: str):
|
||||||
|
cmd = [
|
||||||
|
"ffmpeg",
|
||||||
|
"-y",
|
||||||
|
"-hide_banner",
|
||||||
|
"-loglevel", "error",
|
||||||
|
"-i", src_path,
|
||||||
|
"-frames:v", "1",
|
||||||
|
dst_path,
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
*cmd,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
)
|
||||||
|
except FileNotFoundError as e:
|
||||||
|
raise RuntimeError("未找到 ffmpeg,可执行文件需要在 PATH 中") from e
|
||||||
|
|
||||||
|
stdout, stderr = await proc.communicate()
|
||||||
|
if proc.returncode != 0:
|
||||||
|
message = stderr.decode().strip() or stdout.decode().strip() or "ffmpeg 执行失败"
|
||||||
|
raise RuntimeError(message)
|
||||||
|
|
||||||
|
|
||||||
|
async def _generate_video_thumb(video_bytes: bytes, rel: str, w: int, h: int, fit: str) -> Tuple[bytes, str]:
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
suffix = Path(rel).suffix or ".mp4"
|
||||||
|
src_tmp = tempfile.NamedTemporaryFile(suffix=suffix, delete=False)
|
||||||
|
src_path = src_tmp.name
|
||||||
|
try:
|
||||||
|
src_tmp.write(video_bytes)
|
||||||
|
src_tmp.flush()
|
||||||
|
finally:
|
||||||
|
src_tmp.close()
|
||||||
|
|
||||||
|
dst_tmp = tempfile.NamedTemporaryFile(suffix=".png", delete=False)
|
||||||
|
dst_path = dst_tmp.name
|
||||||
|
dst_tmp.close()
|
||||||
|
|
||||||
|
try:
|
||||||
|
await _run_ffmpeg_extract_frame(src_path, dst_path)
|
||||||
|
with Image.open(dst_path) as im:
|
||||||
|
im.load()
|
||||||
|
return _image_to_webp(im, w, h, fit)
|
||||||
|
finally:
|
||||||
|
with suppress(FileNotFoundError):
|
||||||
|
Path(src_path).unlink()
|
||||||
|
with suppress(FileNotFoundError):
|
||||||
|
Path(dst_path).unlink()
|
||||||
|
|
||||||
|
|
||||||
async def get_or_create_thumb(adapter, adapter_id: int, root: str, rel: str, w: int, h: int, fit: str = 'cover'):
|
async def get_or_create_thumb(adapter, adapter_id: int, root: str, rel: str, w: int, h: int, fit: str = 'cover'):
|
||||||
stat = await adapter.stat_file(root, rel)
|
stat = await adapter.stat_file(root, rel)
|
||||||
if stat['size'] > MAX_SOURCE_SIZE:
|
size = int(stat.get('size') or 0)
|
||||||
|
is_video = is_video_filename(rel)
|
||||||
|
if not is_video and size > MAX_IMAGE_SOURCE_SIZE:
|
||||||
raise HTTPException(400, detail="Image too large for thumbnail")
|
raise HTTPException(400, detail="Image too large for thumbnail")
|
||||||
|
|
||||||
key = _cache_key(adapter_id, rel, stat['size'], int(
|
key = _cache_key(adapter_id, rel, size, int(
|
||||||
stat['mtime']), w, h, fit)
|
stat.get('mtime', 0)), w, h, fit)
|
||||||
path = _cache_path(key)
|
path = _cache_path(key)
|
||||||
if path.exists():
|
if path.exists():
|
||||||
return path.read_bytes(), 'image/webp', key
|
return path.read_bytes(), 'image/webp', key
|
||||||
@@ -119,14 +294,33 @@ async def get_or_create_thumb(adapter, adapter_id: int, root: str, rel: str, w:
|
|||||||
thumb_bytes, mime = None, None
|
thumb_bytes, mime = None, None
|
||||||
|
|
||||||
if not thumb_bytes:
|
if not thumb_bytes:
|
||||||
read_data = await adapter.read_file(root, rel)
|
if is_video:
|
||||||
try:
|
try:
|
||||||
thumb_bytes, mime = generate_thumb(
|
video_bytes = await _read_video_prefix(adapter, root, rel, size)
|
||||||
read_data, w, h, fit, is_raw=is_raw_filename(rel))
|
except HTTPException:
|
||||||
except Exception as e:
|
raise
|
||||||
print(e)
|
except Exception as e:
|
||||||
raise HTTPException(
|
print(f"Video prefix read failed: {e}")
|
||||||
500, detail=f"Thumbnail generation failed: {e}")
|
raise HTTPException(500, detail=f"Video read failed: {e}")
|
||||||
|
|
||||||
|
if not video_bytes:
|
||||||
|
raise HTTPException(500, detail="Unable to read video data for thumbnail")
|
||||||
|
|
||||||
|
try:
|
||||||
|
thumb_bytes, mime = await _generate_video_thumb(video_bytes, rel, w, h, fit)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Video thumbnail generation failed: {e}")
|
||||||
|
raise HTTPException(
|
||||||
|
500, detail=f"Video thumbnail generation failed: {e}")
|
||||||
|
else:
|
||||||
|
read_data = await adapter.read_file(root, rel)
|
||||||
|
try:
|
||||||
|
thumb_bytes, mime = generate_thumb(
|
||||||
|
read_data, w, h, fit, is_raw=is_raw_filename(rel))
|
||||||
|
except Exception as e:
|
||||||
|
print(e)
|
||||||
|
raise HTTPException(
|
||||||
|
500, detail=f"Thumbnail generation failed: {e}")
|
||||||
|
|
||||||
if thumb_bytes:
|
if thumb_bytes:
|
||||||
path.write_bytes(thumb_bytes)
|
path.write_bytes(thumb_bytes)
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import aiofiles
|
|||||||
from models import StorageAdapter
|
from models import StorageAdapter
|
||||||
from .adapters.registry import runtime_registry
|
from .adapters.registry import runtime_registry
|
||||||
from api.response import page
|
from api.response import page
|
||||||
from .thumbnail import is_image_filename, is_raw_filename
|
from .thumbnail import is_image_filename, is_raw_filename, is_video_filename
|
||||||
from services.processors.registry import get as get_processor
|
from services.processors.registry import get as get_processor
|
||||||
from services.tasks import task_service
|
from services.tasks import task_service
|
||||||
from services.logging import LogService
|
from services.logging import LogService
|
||||||
@@ -143,7 +143,7 @@ async def list_virtual_dir(path: str, page_num: int = 1, page_size: int = 50, so
|
|||||||
norm = (path if path.startswith('/') else '/' + path).rstrip('/') or '/'
|
norm = (path if path.startswith('/') else '/' + path).rstrip('/') or '/'
|
||||||
adapters = await StorageAdapter.filter(enabled=True)
|
adapters = await StorageAdapter.filter(enabled=True)
|
||||||
|
|
||||||
child_mount_entries = []
|
child_mount_entries: List[str] = []
|
||||||
norm_prefix = norm.rstrip('/')
|
norm_prefix = norm.rstrip('/')
|
||||||
for a in adapters:
|
for a in adapters:
|
||||||
if a.path == norm:
|
if a.path == norm:
|
||||||
@@ -154,6 +154,28 @@ async def list_virtual_dir(path: str, page_num: int = 1, page_size: int = 50, so
|
|||||||
child_mount_entries.append(tail)
|
child_mount_entries.append(tail)
|
||||||
child_mount_entries = sorted(set(child_mount_entries))
|
child_mount_entries = sorted(set(child_mount_entries))
|
||||||
|
|
||||||
|
sort_field = sort_by.lower()
|
||||||
|
reverse = sort_order.lower() == "desc"
|
||||||
|
|
||||||
|
def build_sort_key(item: Dict) -> Tuple:
|
||||||
|
key = (not bool(item.get("is_dir")),)
|
||||||
|
if sort_field == "name":
|
||||||
|
key += (str(item.get("name", "")).lower(),)
|
||||||
|
elif sort_field == "size":
|
||||||
|
key += (int(item.get("size", 0)),)
|
||||||
|
elif sort_field == "mtime":
|
||||||
|
key += (int(item.get("mtime", 0)),)
|
||||||
|
else:
|
||||||
|
key += (str(item.get("name", "")).lower(),)
|
||||||
|
return key
|
||||||
|
|
||||||
|
def annotate_entry(entry: Dict) -> None:
|
||||||
|
if not entry.get("is_dir"):
|
||||||
|
name = entry.get("name", "")
|
||||||
|
entry["has_thumbnail"] = bool(is_image_filename(name) or is_video_filename(name))
|
||||||
|
else:
|
||||||
|
entry["has_thumbnail"] = False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
adapter_model, rel = await resolve_adapter_by_path(norm)
|
adapter_model, rel = await resolve_adapter_by_path(norm)
|
||||||
adapter_instance = runtime_registry.get(adapter_model.id)
|
adapter_instance = runtime_registry.get(adapter_model.id)
|
||||||
@@ -173,57 +195,57 @@ async def list_virtual_dir(path: str, page_num: int = 1, page_size: int = 50, so
|
|||||||
effective_root = ''
|
effective_root = ''
|
||||||
rel = ''
|
rel = ''
|
||||||
|
|
||||||
adapter_entries = []
|
adapter_entries_page: List[Dict] = []
|
||||||
|
adapter_entries_for_merge: List[Dict] = []
|
||||||
adapter_total = 0
|
adapter_total = 0
|
||||||
covered = set()
|
covered = set()
|
||||||
|
|
||||||
if adapter_model and adapter_instance:
|
if adapter_model and adapter_instance:
|
||||||
list_dir = await _ensure_method(adapter_instance, "list_dir")
|
list_dir = await _ensure_method(adapter_instance, "list_dir")
|
||||||
try:
|
try:
|
||||||
adapter_entries, adapter_total = await list_dir(effective_root, rel, page_num, page_size, sort_by, sort_order)
|
adapter_entries_page, adapter_total = await list_dir(effective_root, rel, page_num, page_size, sort_by, sort_order)
|
||||||
except NotADirectoryError:
|
except NotADirectoryError:
|
||||||
raise HTTPException(400, detail="Not a directory")
|
raise HTTPException(400, detail="Not a directory")
|
||||||
|
|
||||||
for item in adapter_entries:
|
adapter_entries_for_merge = adapter_entries_page
|
||||||
|
|
||||||
|
# 存在挂载节点且适配器结果被分页时,补齐完整列表以便合并排序
|
||||||
|
if child_mount_entries and adapter_total > len(adapter_entries_page):
|
||||||
|
full_page_size = adapter_total
|
||||||
|
if full_page_size > 0:
|
||||||
|
adapter_entries_for_merge, adapter_total = await list_dir(
|
||||||
|
effective_root, rel, 1, full_page_size, sort_by, sort_order
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
adapter_entries_for_merge = adapter_entries_page
|
||||||
|
|
||||||
|
for item in adapter_entries_for_merge:
|
||||||
covered.add(item["name"])
|
covered.add(item["name"])
|
||||||
|
|
||||||
mount_entries = []
|
mount_entries = []
|
||||||
for name in child_mount_entries:
|
for name in child_mount_entries:
|
||||||
if name not in covered:
|
if name not in covered:
|
||||||
mount_entries.append({"name": name, "is_dir": True,
|
mount_entries.append({"name": name, "is_dir": True,
|
||||||
"size": 0, "mtime": 0, "type": "mount", "is_image": False})
|
"size": 0, "mtime": 0, "type": "mount", "has_thumbnail": False})
|
||||||
|
|
||||||
for ent in adapter_entries:
|
|
||||||
if not ent.get('is_dir'):
|
|
||||||
ent['is_image'] = is_image_filename(ent['name'])
|
|
||||||
else:
|
|
||||||
ent['is_image'] = False
|
|
||||||
|
|
||||||
all_entries = adapter_entries + mount_entries
|
|
||||||
|
|
||||||
if mount_entries:
|
if mount_entries:
|
||||||
reverse = sort_order.lower() == "desc"
|
for ent in adapter_entries_for_merge:
|
||||||
def get_sort_key(item):
|
annotate_entry(ent)
|
||||||
key = (not item.get("is_dir"),)
|
combined_entries = adapter_entries_for_merge + [
|
||||||
sort_field = sort_by.lower()
|
{**ent, "has_thumbnail": False} for ent in mount_entries
|
||||||
if sort_field == "name":
|
]
|
||||||
key += (item["name"].lower(),)
|
combined_entries.sort(key=build_sort_key, reverse=reverse)
|
||||||
elif sort_field == "size":
|
|
||||||
key += (item.get("size", 0),)
|
total_entries = len(combined_entries)
|
||||||
elif sort_field == "mtime":
|
|
||||||
key += (item.get("mtime", 0),)
|
|
||||||
else:
|
|
||||||
key += (item["name"].lower(),)
|
|
||||||
return key
|
|
||||||
all_entries.sort(key=get_sort_key, reverse=reverse)
|
|
||||||
|
|
||||||
total_entries = adapter_total + len(mount_entries)
|
|
||||||
start_idx = (page_num - 1) * page_size
|
start_idx = (page_num - 1) * page_size
|
||||||
end_idx = start_idx + page_size
|
end_idx = start_idx + page_size
|
||||||
page_entries = all_entries[start_idx:end_idx]
|
page_entries = combined_entries[start_idx:end_idx]
|
||||||
return page(page_entries, total_entries, page_num, page_size)
|
return page(page_entries, total_entries, page_num, page_size)
|
||||||
|
|
||||||
return page(adapter_entries, adapter_total, page_num, page_size)
|
annotate_entry_list = adapter_entries_page or []
|
||||||
|
for ent in annotate_entry_list:
|
||||||
|
annotate_entry(ent)
|
||||||
|
return page(adapter_entries_page, adapter_total, page_num, page_size)
|
||||||
|
|
||||||
|
|
||||||
async def read_file(path: str) -> Union[bytes, Any]:
|
async def read_file(path: str) -> Union[bytes, Any]:
|
||||||
@@ -285,7 +307,12 @@ async def write_file_stream(path: str, data_iter: AsyncIterator[bytes], overwrit
|
|||||||
async def make_dir(path: str):
|
async def make_dir(path: str):
|
||||||
adapter_instance, _, root, rel = await resolve_adapter_and_rel(path)
|
adapter_instance, _, root, rel = await resolve_adapter_and_rel(path)
|
||||||
if not rel:
|
if not rel:
|
||||||
raise HTTPException(400, detail="Cannot create root")
|
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")
|
mkdir_func = await _ensure_method(adapter_instance, "mkdir")
|
||||||
await mkdir_func(root, rel)
|
await mkdir_func(root, rel)
|
||||||
await LogService.action("virtual_fs", f"Created directory {path}", details={"path": path})
|
await LogService.action("virtual_fs", f"Created directory {path}", details={"path": path})
|
||||||
@@ -575,6 +602,9 @@ async def stat_file(path: str):
|
|||||||
is_dir = bool(info.get("is_dir"))
|
is_dir = bool(info.get("is_dir"))
|
||||||
except Exception:
|
except Exception:
|
||||||
is_dir = False
|
is_dir = False
|
||||||
|
rel_name = rel.rstrip('/').split('/')[-1] if rel else path.rstrip('/').split('/')[-1]
|
||||||
|
name_hint = str(info.get("name") or rel_name or "")
|
||||||
|
info["has_thumbnail"] = bool(not is_dir and (is_image_filename(name_hint) or is_video_filename(name_hint)))
|
||||||
if not is_dir:
|
if not is_dir:
|
||||||
vector_index = await _gather_vector_index(path)
|
vector_index = await _gather_vector_index(path)
|
||||||
if vector_index is not None:
|
if vector_index is not None:
|
||||||
|
|||||||
102
templates/email/password_reset.html
Normal file
102
templates/email/password_reset.html
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>Foxel 密码重置</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
background: #f4f7fb;
|
||||||
|
font-family: 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
margin: 0;
|
||||||
|
padding: 32px 0;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
.wrapper {
|
||||||
|
max-width: 560px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: 0 12px 40px rgba(15, 23, 42, 0.12);
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid rgba(99, 102, 241, 0.12);
|
||||||
|
}
|
||||||
|
.header {
|
||||||
|
background: linear-gradient(120deg, #4f46e5, #7c3aed);
|
||||||
|
padding: 32px;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
.header h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 24px;
|
||||||
|
letter-spacing: 0.2px;
|
||||||
|
}
|
||||||
|
.content {
|
||||||
|
padding: 32px;
|
||||||
|
}
|
||||||
|
.content p {
|
||||||
|
margin: 16px 0;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.cta {
|
||||||
|
display: block;
|
||||||
|
margin: 32px 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.cta a {
|
||||||
|
display: inline-block;
|
||||||
|
background: linear-gradient(120deg, #6366f1, #8b5cf6);
|
||||||
|
color: #ffffff;
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 14px 32px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-weight: 600;
|
||||||
|
box-shadow: 0 8px 24px rgba(79, 70, 229, 0.32);
|
||||||
|
}
|
||||||
|
.info-box {
|
||||||
|
background: #f5f3ff;
|
||||||
|
border: 1px solid rgba(107, 114, 128, 0.1);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 18px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
.footer {
|
||||||
|
padding: 24px 32px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #6b7280;
|
||||||
|
line-height: 1.6;
|
||||||
|
background: #fafafa;
|
||||||
|
border-top: 1px solid rgba(15, 23, 42, 0.04);
|
||||||
|
}
|
||||||
|
.footer a {
|
||||||
|
color: #6366f1;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="wrapper">
|
||||||
|
<div class="card">
|
||||||
|
<div class="header">
|
||||||
|
<h1>重置你的 Foxel 密码</h1>
|
||||||
|
</div>
|
||||||
|
<div class="content">
|
||||||
|
<p>你好,${username}。</p>
|
||||||
|
<p>我们收到了重置你 Foxel 帐号密码的请求。请点击下方按钮完成密码重置操作:</p>
|
||||||
|
<div class="cta">
|
||||||
|
<a href="${reset_link}" target="_blank" rel="noopener">重置密码</a>
|
||||||
|
</div>
|
||||||
|
<p>如果按钮无法点击,你也可以复制下面的链接到浏览器打开:</p>
|
||||||
|
<div class="info-box">
|
||||||
|
<div style="word-break: break-all;">${reset_link}</div>
|
||||||
|
</div>
|
||||||
|
<p>该链接在 ${expire_minutes} 分钟内有效。若你未发起此请求,请忽略本邮件,你的密码不会发生变化。</p>
|
||||||
|
</div>
|
||||||
|
<div class="footer">
|
||||||
|
<div>此邮件由 Foxel 系统自动发送,请勿直接回复。</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
97
templates/email/test.html
Normal file
97
templates/email/test.html
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>Foxel 邮件配置测试</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 32px 0;
|
||||||
|
background: linear-gradient(135deg, #eef2ff, #e0f2fe);
|
||||||
|
font-family: 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
.wrapper {
|
||||||
|
max-width: 560px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 18px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.18);
|
||||||
|
border: 1px solid rgba(99, 102, 241, 0.08);
|
||||||
|
}
|
||||||
|
.banner {
|
||||||
|
background: linear-gradient(120deg, #1d4ed8, #6366f1);
|
||||||
|
padding: 36px;
|
||||||
|
color: #ffffff;
|
||||||
|
letter-spacing: 0.2px;
|
||||||
|
}
|
||||||
|
.banner h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
.content {
|
||||||
|
padding: 32px;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 6px 14px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(59, 130, 246, 0.12);
|
||||||
|
color: #1d4ed8;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 13px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.cta-box {
|
||||||
|
margin-top: 32px;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: linear-gradient(135deg, rgba(99, 102, 241, 0.08), rgba(14, 165, 233, 0.08));
|
||||||
|
border: 1px solid rgba(99, 102, 241, 0.12);
|
||||||
|
}
|
||||||
|
.cta-box strong {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
.footer {
|
||||||
|
padding: 24px 32px;
|
||||||
|
background: #f8fafc;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #64748b;
|
||||||
|
border-top: 1px solid rgba(148, 163, 184, 0.18);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="wrapper">
|
||||||
|
<div class="card">
|
||||||
|
<div class="banner">
|
||||||
|
<h1>Foxel 邮件服务已连通</h1>
|
||||||
|
</div>
|
||||||
|
<div class="content">
|
||||||
|
<div class="badge">Mail Delivery Test</div>
|
||||||
|
<p>你好,${username}!</p>
|
||||||
|
<p>
|
||||||
|
这是一封来自 <strong>Foxel</strong> 的测试邮件。如果你能够正常阅读到这段内容,说明系统已经成功与配置的邮箱服务建立连接。
|
||||||
|
</p>
|
||||||
|
<div class="cta-box">
|
||||||
|
<strong>接下来可以做什么?</strong>
|
||||||
|
<ul style="margin: 0; padding-left: 18px; line-height: 1.7;">
|
||||||
|
<li>继续完善系统通知、密码重置等业务功能</li>
|
||||||
|
<li>在后台页面中自定义更精美的邮件模板</li>
|
||||||
|
<li>保持发送凭据安全,避免泄露</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="footer">
|
||||||
|
本邮件由 Foxel 系统自动发送,请勿直接回复。如非本人操作,请忽略此邮件。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
29
uv.lock
generated
29
uv.lock
generated
@@ -373,6 +373,28 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/3e/7c/15ad426257615f9be8caf7f97990cf3dcbb5b8dd7ed7e0db581a1c4759dd/cryptography-46.0.2-cp38-abi3-win_arm64.whl", hash = "sha256:91447f2b17e83c9e0c89f133119d83f94ce6e0fb55dd47da0a959316e6e9cfa1", size = 2918153, upload-time = "2025-10-01T00:28:51.003Z" },
|
{ 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]]
|
[[package]]
|
||||||
name = "fastapi"
|
name = "fastapi"
|
||||||
version = "0.116.1"
|
version = "0.116.1"
|
||||||
@@ -399,6 +421,7 @@ dependencies = [
|
|||||||
{ name = "paramiko" },
|
{ name = "paramiko" },
|
||||||
{ name = "passlib", extra = ["bcrypt"] },
|
{ name = "passlib", extra = ["bcrypt"] },
|
||||||
{ name = "pillow" },
|
{ name = "pillow" },
|
||||||
|
{ name = "pydantic", extra = ["email"] },
|
||||||
{ name = "pyjwt" },
|
{ name = "pyjwt" },
|
||||||
{ name = "pymilvus", extra = ["milvus-lite"] },
|
{ name = "pymilvus", extra = ["milvus-lite"] },
|
||||||
{ name = "pysocks" },
|
{ name = "pysocks" },
|
||||||
@@ -420,6 +443,7 @@ requires-dist = [
|
|||||||
{ name = "paramiko", specifier = ">=4.0.0" },
|
{ name = "paramiko", specifier = ">=4.0.0" },
|
||||||
{ name = "passlib", extras = ["bcrypt"], specifier = ">=1.7.4" },
|
{ name = "passlib", extras = ["bcrypt"], specifier = ">=1.7.4" },
|
||||||
{ name = "pillow", specifier = ">=11.3.0" },
|
{ name = "pillow", specifier = ">=11.3.0" },
|
||||||
|
{ name = "pydantic", extras = ["email"], specifier = ">=2.11.7" },
|
||||||
{ name = "pyjwt", specifier = ">=2.10.1" },
|
{ name = "pyjwt", specifier = ">=2.10.1" },
|
||||||
{ name = "pymilvus", extras = ["milvus-lite"], specifier = ">=2.6.2" },
|
{ name = "pymilvus", extras = ["milvus-lite"], specifier = ">=2.6.2" },
|
||||||
{ name = "pysocks", specifier = ">=1.7.1" },
|
{ 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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "pydantic-core"
|
name = "pydantic-core"
|
||||||
version = "2.33.2"
|
version = "2.33.2"
|
||||||
|
|||||||
@@ -18,9 +18,14 @@ function AppInner() {
|
|||||||
const status = await getStatus();
|
const status = await getStatus();
|
||||||
setStatus(status);
|
setStatus(status);
|
||||||
document.title = status.title;
|
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) {
|
if (favicon) {
|
||||||
favicon.href = status.logo;
|
favicon.href = status.favicon || status.logo;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to check initialization status:", error);
|
console.error("Failed to check initialization status:", error);
|
||||||
|
|||||||
@@ -32,6 +32,15 @@ export interface UpdateMePayload {
|
|||||||
new_password?: string;
|
new_password?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PasswordResetRequestPayload {
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PasswordResetConfirmPayload {
|
||||||
|
token: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
export const authApi = {
|
export const authApi = {
|
||||||
register: async (username: string, password: string, email?: string, full_name?: string): Promise<any> => {
|
register: async (username: string, password: string, email?: string, full_name?: string): Promise<any> => {
|
||||||
return request('/auth/register', {
|
return request('/auth/register', {
|
||||||
@@ -68,4 +77,19 @@ export const authApi = {
|
|||||||
json: payload,
|
json: payload,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
requestPasswordReset: async (payload: PasswordResetRequestPayload) => {
|
||||||
|
return await request('/auth/password-reset/request', {
|
||||||
|
method: 'POST',
|
||||||
|
json: payload,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
verifyPasswordResetToken: async (token: string) => {
|
||||||
|
return await request<{ username: string; email: string }>('/auth/password-reset/verify?token=' + encodeURIComponent(token));
|
||||||
|
},
|
||||||
|
confirmPasswordReset: async (payload: PasswordResetConfirmPayload) => {
|
||||||
|
return await request('/auth/password-reset/confirm', {
|
||||||
|
method: 'POST',
|
||||||
|
json: payload,
|
||||||
|
});
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export interface SystemStatus {
|
|||||||
version: string;
|
version: string;
|
||||||
title: string;
|
title: string;
|
||||||
logo: string;
|
logo: string;
|
||||||
|
favicon: string;
|
||||||
is_initialized: boolean;
|
is_initialized: boolean;
|
||||||
app_domain?: string;
|
app_domain?: string;
|
||||||
file_domain?: string;
|
file_domain?: string;
|
||||||
|
|||||||
41
web/src/api/email.ts
Normal file
41
web/src/api/email.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import request from './client';
|
||||||
|
|
||||||
|
export interface EmailTestPayload {
|
||||||
|
to: string;
|
||||||
|
subject: string;
|
||||||
|
template?: string;
|
||||||
|
context?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendTestEmail(payload: EmailTestPayload) {
|
||||||
|
return request<{ task_id: string }>('/email/test', {
|
||||||
|
method: 'POST',
|
||||||
|
json: {
|
||||||
|
template: 'test',
|
||||||
|
context: {},
|
||||||
|
...payload,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listEmailTemplates() {
|
||||||
|
return request<{ templates: string[] }>('/email/templates');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getEmailTemplate(name: string) {
|
||||||
|
return request<{ name: string; content: string }>(`/email/templates/${encodeURIComponent(name)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateEmailTemplate(name: string, content: string) {
|
||||||
|
return request(`/email/templates/${encodeURIComponent(name)}`, {
|
||||||
|
method: 'POST',
|
||||||
|
json: { content },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function previewEmailTemplate(name: string, context: Record<string, unknown>) {
|
||||||
|
return request<{ html: string }>(`/email/templates/${encodeURIComponent(name)}/preview`, {
|
||||||
|
method: 'POST',
|
||||||
|
json: { context },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ export interface VfsEntry {
|
|||||||
size: number;
|
size: number;
|
||||||
mtime: number;
|
mtime: number;
|
||||||
type?: string;
|
type?: string;
|
||||||
is_image?: boolean;
|
has_thumbnail?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DirListing {
|
export interface DirListing {
|
||||||
|
|||||||
@@ -54,8 +54,8 @@ const DEFAULT_TONE: RgbColor = { r: 28, g: 32, b: 46 };
|
|||||||
|
|
||||||
const isImageEntry = (ent: VfsEntry) => {
|
const isImageEntry = (ent: VfsEntry) => {
|
||||||
if (ent.is_dir) return false;
|
if (ent.is_dir) return false;
|
||||||
const maybe = ent as VfsEntry & { is_image?: boolean };
|
const maybe = ent as VfsEntry & { has_thumbnail?: boolean };
|
||||||
if (typeof maybe.is_image === 'boolean' && maybe.is_image) return true;
|
if (typeof maybe.has_thumbnail === 'boolean' && maybe.has_thumbnail) return true;
|
||||||
const ext = ent.name.split('.').pop()?.toLowerCase();
|
const ext = ent.name.split('.').pop()?.toLowerCase();
|
||||||
if (!ext) return false;
|
if (!ext) return false;
|
||||||
return ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'avif', 'ico', 'tif', 'tiff', 'svg', 'heic', 'heif', 'arw', 'cr2', 'cr3', 'nef', 'rw2', 'orf', 'pef', 'dng'].includes(ext);
|
return ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'avif', 'ico', 'tif', 'tiff', 'svg', 'heic', 'heif', 'arw', 'cr2', 'cr3', 'nef', 'rw2', 'orf', 'pef', 'dng'].includes(ext);
|
||||||
|
|||||||
@@ -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 td { background: rgba(24,144,255,0.12) !important; }
|
||||||
.row-selected:hover td { background: rgba(24,144,255,0.2) !important; }
|
.row-selected:hover td { background: rgba(24,144,255,0.2) !important; }
|
||||||
|
|
||||||
.fx-grid { display:flex; flex-wrap:wrap; gap:20px; }
|
.fx-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(160px,1fr)); gap:20px; align-content:start; }
|
||||||
.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-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.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.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); }
|
.fx-grid-item:hover { background: var(--ant-color-fill, #ededed); box-shadow:0 1px 4px rgba(0,0,0,.06); }
|
||||||
|
|||||||
@@ -127,6 +127,8 @@ export const en = {
|
|||||||
|
|
||||||
// Context menu
|
// Context menu
|
||||||
'Upload File': 'Upload File',
|
'Upload File': 'Upload File',
|
||||||
|
'Upload Files': 'Upload Files',
|
||||||
|
'Upload Folder': 'Upload Folder',
|
||||||
'Open': 'Open',
|
'Open': 'Open',
|
||||||
'Open With': 'Open With',
|
'Open With': 'Open With',
|
||||||
'Default': 'Default',
|
'Default': 'Default',
|
||||||
@@ -137,6 +139,31 @@ export const en = {
|
|||||||
'Details': 'Details',
|
'Details': 'Details',
|
||||||
'Get Direct Link': 'Get Direct Link',
|
'Get Direct Link': 'Get Direct Link',
|
||||||
|
|
||||||
|
// Upload modal
|
||||||
|
'Total progress': 'Total progress',
|
||||||
|
'Upload bytes summary': '{uploaded} / {total}',
|
||||||
|
'Upload task summary': 'Tasks: {completed} / {total} completed, {pending} pending, {failures} failed',
|
||||||
|
'Overwrite confirmation required': 'Overwrite confirmation required',
|
||||||
|
'Target already exists: {path}': 'Target already exists: {path}',
|
||||||
|
'Overwrite': 'Overwrite',
|
||||||
|
'Skip': 'Skip',
|
||||||
|
'Overwrite All': 'Overwrite All',
|
||||||
|
'Skip All': 'Skip All',
|
||||||
|
'Directory': 'Directory',
|
||||||
|
'Creating directory...': 'Creating directory...',
|
||||||
|
'Directory ready': 'Directory ready',
|
||||||
|
'Create directory failed': 'Create directory failed',
|
||||||
|
'Waiting to create': 'Waiting to create',
|
||||||
|
'Waiting for overwrite decision': 'Waiting for overwrite decision',
|
||||||
|
'Waiting to upload': 'Waiting to upload',
|
||||||
|
'Skipped': 'Skipped',
|
||||||
|
'Upload succeeded': 'Upload succeeded',
|
||||||
|
'Upload failed': 'Upload failed',
|
||||||
|
'No items selected for upload': 'No items selected for upload',
|
||||||
|
'No uploadable files or directories found': 'No uploadable files or directories found',
|
||||||
|
'Missing file content': 'Missing file content',
|
||||||
|
'Directory conflicts with existing file': 'A file with the same name already exists at the target location',
|
||||||
|
|
||||||
// Side nav modals
|
// Side nav modals
|
||||||
'Join Community': 'Join Community',
|
'Join Community': 'Join Community',
|
||||||
'Scan to join WeChat group': 'Scan to join WeChat group',
|
'Scan to join WeChat group': 'Scan to join WeChat group',
|
||||||
@@ -257,7 +284,17 @@ export const en = {
|
|||||||
'Custom CSS': 'Custom CSS',
|
'Custom CSS': 'Custom CSS',
|
||||||
'Save': 'Save',
|
'Save': 'Save',
|
||||||
'App Settings': 'App Settings',
|
'App Settings': 'App Settings',
|
||||||
|
'Email Settings': 'Email Settings',
|
||||||
'AI Settings': 'AI 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',
|
'Vision Model': 'Vision Model',
|
||||||
'Embedding Model': 'Embedding Model',
|
'Embedding Model': 'Embedding Model',
|
||||||
'Embedding Dimension': 'Embedding Dimension',
|
'Embedding Dimension': 'Embedding Dimension',
|
||||||
@@ -301,8 +338,80 @@ export const en = {
|
|||||||
'Clear Vector DB': 'Clear Vector DB',
|
'Clear Vector DB': 'Clear Vector DB',
|
||||||
'App Name': 'App Name',
|
'App Name': 'App Name',
|
||||||
'Logo URL': 'Logo URL',
|
'Logo URL': 'Logo URL',
|
||||||
|
'Favicon URL': 'Favicon URL',
|
||||||
'App Domain': 'App Domain',
|
'App Domain': 'App Domain',
|
||||||
'File Domain': 'File 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 URL': 'Vision API URL',
|
||||||
'Vision API Key': 'Vision API Key',
|
'Vision API Key': 'Vision API Key',
|
||||||
'Embedding API URL': 'Embedding API URL',
|
'Embedding API URL': 'Embedding API URL',
|
||||||
@@ -442,9 +551,11 @@ export const en = {
|
|||||||
'Level': 'Level',
|
'Level': 'Level',
|
||||||
'Source': 'Source',
|
'Source': 'Source',
|
||||||
'Message': 'Message',
|
'Message': 'Message',
|
||||||
|
'User ID': 'User ID',
|
||||||
'Search source': 'Search source',
|
'Search source': 'Search source',
|
||||||
'Clear': 'Clear',
|
'Clear': 'Clear',
|
||||||
'Log Details': 'Log Details',
|
'Log Details': 'Log Details',
|
||||||
|
'Raw Log': 'Raw Log',
|
||||||
|
|
||||||
// Backup
|
// Backup
|
||||||
'Export started, check your downloads.': 'Export started, check your downloads.',
|
'Export started, check your downloads.': 'Export started, check your downloads.',
|
||||||
@@ -571,7 +682,6 @@ export const en = {
|
|||||||
'This is the first account with full permissions': 'This is the first account with full permissions',
|
'This is the first account with full permissions': 'This is the first account with full permissions',
|
||||||
'Username': 'Username',
|
'Username': 'Username',
|
||||||
'Please input a valid email!': 'Please input a valid email!',
|
'Please input a valid email!': 'Please input a valid email!',
|
||||||
'Confirm Password': 'Confirm Password',
|
|
||||||
'Please confirm your password!': 'Please confirm your password!',
|
'Please confirm your password!': 'Please confirm your password!',
|
||||||
'Passwords do not match!': 'Passwords do not match!',
|
'Passwords do not match!': 'Passwords do not match!',
|
||||||
'System Initialization': 'System Initialization',
|
'System Initialization': 'System Initialization',
|
||||||
|
|||||||
@@ -63,12 +63,32 @@ export const zh = {
|
|||||||
'Sign In': '登录',
|
'Sign In': '登录',
|
||||||
'Please enter username and password': '请输入用户名与密码',
|
'Please enter username and password': '请输入用户名与密码',
|
||||||
'Login failed': '登录失败',
|
'Login failed': '登录失败',
|
||||||
|
'Forgot Password?': '忘记密码?',
|
||||||
'Your next-generation file manager': '您的下一代文件管理系统',
|
'Your next-generation file manager': '您的下一代文件管理系统',
|
||||||
'Cross-platform sync, access anywhere': '跨平台同步,随时随地访问',
|
'Cross-platform sync, access anywhere': '跨平台同步,随时随地访问',
|
||||||
'AI-powered search for quick find': 'AI 驱动的智能搜索,快速定位文件',
|
'AI-powered search for quick find': 'AI 驱动的智能搜索,快速定位文件',
|
||||||
'Flexible sharing and collaboration': '灵活的分享与协作,提升团队效率',
|
'Flexible sharing and collaboration': '灵活的分享与协作,提升团队效率',
|
||||||
'Powerful automation to simplify tasks': '强大的自动化工作流,简化繁琐任务',
|
'Powerful automation to simplify tasks': '强大的自动化工作流,简化繁琐任务',
|
||||||
'Join our community:': '加入我们的社区:',
|
'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
|
// Share page
|
||||||
'Refresh': '刷新',
|
'Refresh': '刷新',
|
||||||
@@ -129,6 +149,8 @@ export const zh = {
|
|||||||
|
|
||||||
// Context menu
|
// Context menu
|
||||||
'Upload File': '上传文件',
|
'Upload File': '上传文件',
|
||||||
|
'Upload Files': '上传文件',
|
||||||
|
'Upload Folder': '上传文件夹',
|
||||||
'Open': '打开',
|
'Open': '打开',
|
||||||
'Open With': '打开方式',
|
'Open With': '打开方式',
|
||||||
'Default': '默认',
|
'Default': '默认',
|
||||||
@@ -139,6 +161,31 @@ export const zh = {
|
|||||||
'Details': '详情',
|
'Details': '详情',
|
||||||
'Get Direct Link': '获取直链',
|
'Get Direct Link': '获取直链',
|
||||||
|
|
||||||
|
// Upload modal
|
||||||
|
'Total progress': '总体进度',
|
||||||
|
'Upload bytes summary': '{uploaded} / {total}',
|
||||||
|
'Upload task summary': '任务:已完成 {completed} / {total},待处理 {pending},失败 {failures}',
|
||||||
|
'Overwrite confirmation required': '需要确认是否覆盖',
|
||||||
|
'Target already exists: {path}': '目标已存在:{path}',
|
||||||
|
'Overwrite': '覆盖',
|
||||||
|
'Skip': '跳过',
|
||||||
|
'Overwrite All': '全部覆盖',
|
||||||
|
'Skip All': '全部跳过',
|
||||||
|
'Directory': '目录',
|
||||||
|
'Creating directory...': '正在创建目录...',
|
||||||
|
'Directory ready': '目录已就绪',
|
||||||
|
'Create directory failed': '创建目录失败',
|
||||||
|
'Waiting to create': '等待创建',
|
||||||
|
'Waiting for overwrite decision': '等待覆盖处理',
|
||||||
|
'Waiting to upload': '等待上传',
|
||||||
|
'Skipped': '已跳过',
|
||||||
|
'Upload succeeded': '上传成功',
|
||||||
|
'Upload failed': '上传失败',
|
||||||
|
'No items selected for upload': '未选择任何可上传项',
|
||||||
|
'No uploadable files or directories found': '未找到可上传的文件或目录',
|
||||||
|
'Missing file content': '缺少文件内容',
|
||||||
|
'Directory conflicts with existing file': '目标存在同名文件,无法创建目录',
|
||||||
|
|
||||||
// Side nav modals
|
// Side nav modals
|
||||||
'Join Community': '加入社区',
|
'Join Community': '加入社区',
|
||||||
'Scan to join WeChat group': '微信扫码加入交流群',
|
'Scan to join WeChat group': '微信扫码加入交流群',
|
||||||
@@ -258,7 +305,17 @@ export const zh = {
|
|||||||
'Custom CSS': '自定义 CSS',
|
'Custom CSS': '自定义 CSS',
|
||||||
'Save': '保存',
|
'Save': '保存',
|
||||||
'App Settings': '应用设置',
|
'App Settings': '应用设置',
|
||||||
|
'Email Settings': '邮箱设置',
|
||||||
'AI Settings': 'AI设置',
|
'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': '选择模板',
|
'Choose Template': '选择模板',
|
||||||
'Configure Provider': '配置提供商',
|
'Configure Provider': '配置提供商',
|
||||||
'Back to Templates': '返回选择',
|
'Back to Templates': '返回选择',
|
||||||
@@ -306,8 +363,62 @@ export const zh = {
|
|||||||
'Clear Vector DB': '清空向量库',
|
'Clear Vector DB': '清空向量库',
|
||||||
'App Name': '应用名称',
|
'App Name': '应用名称',
|
||||||
'Logo URL': 'LOGO地址',
|
'Logo URL': 'LOGO地址',
|
||||||
|
'Favicon URL': 'Favicon 地址',
|
||||||
'App Domain': '应用域名',
|
'App Domain': '应用域名',
|
||||||
'File 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 URL': '视觉模型 API 地址',
|
||||||
'Vision API Key': '视觉模型 API Key',
|
'Vision API Key': '视觉模型 API Key',
|
||||||
'Embedding API URL': '嵌入模型 API 地址',
|
'Embedding API URL': '嵌入模型 API 地址',
|
||||||
@@ -444,9 +555,11 @@ export const zh = {
|
|||||||
'Level': '级别',
|
'Level': '级别',
|
||||||
'Source': '来源',
|
'Source': '来源',
|
||||||
'Message': '消息',
|
'Message': '消息',
|
||||||
|
'User ID': '用户 ID',
|
||||||
'Search source': '搜索来源',
|
'Search source': '搜索来源',
|
||||||
'Clear': '清理',
|
'Clear': '清理',
|
||||||
'Log Details': '日志详情',
|
'Log Details': '日志详情',
|
||||||
|
'Raw Log': '原始日志',
|
||||||
|
|
||||||
// Backup
|
// Backup
|
||||||
'Export started, check your downloads.': '导出已开始,请检查您的下载。',
|
'Export started, check your downloads.': '导出已开始,请检查您的下载。',
|
||||||
@@ -584,7 +697,6 @@ export const zh = {
|
|||||||
'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': '确认密码',
|
|
||||||
'Please confirm your password!': '请确认您的密码!',
|
'Please confirm your password!': '请确认您的密码!',
|
||||||
'Passwords do not match!': '两次输入的密码不一致!',
|
'Passwords do not match!': '两次输入的密码不一致!',
|
||||||
'System Initialization': '系统初始化',
|
'System Initialization': '系统初始化',
|
||||||
|
|||||||
@@ -221,7 +221,7 @@ const SearchDialog: React.FC<SearchDialogProps> = ({ open, onClose }) => {
|
|||||||
size: Number((stat as any)?.size ?? 0),
|
size: Number((stat as any)?.size ?? 0),
|
||||||
mtime: Number((stat as any)?.mtime ?? (stat as any)?.mtime_ms ?? 0),
|
mtime: Number((stat as any)?.mtime ?? (stat as any)?.mtime_ms ?? 0),
|
||||||
type: (stat as any)?.type,
|
type: (stat as any)?.type,
|
||||||
is_image: Boolean((stat as any)?.is_image),
|
has_thumbnail: Boolean((stat as any)?.has_thumbnail),
|
||||||
};
|
};
|
||||||
statCacheRef.current.set(fullPath, entry);
|
statCacheRef.current.set(fullPath, entry);
|
||||||
return entry;
|
return entry;
|
||||||
@@ -522,7 +522,8 @@ const SearchDialog: React.FC<SearchDialogProps> = ({ open, onClose }) => {
|
|||||||
processorHook.setSelectedProcessor(type);
|
processorHook.setSelectedProcessor(type);
|
||||||
processorHook.openProcessorModal(entry);
|
processorHook.openProcessorModal(entry);
|
||||||
}}
|
}}
|
||||||
onUpload={noop}
|
onUploadFile={noop}
|
||||||
|
onUploadDirectory={noop}
|
||||||
onCreateDir={noop}
|
onCreateDir={noop}
|
||||||
onShare={doShare}
|
onShare={doShare}
|
||||||
onGetDirectLink={doGetDirectLink}
|
onGetDirectLink={doGetDirectLink}
|
||||||
|
|||||||
@@ -25,13 +25,16 @@ import { FileDetailModal } from './components/FileDetailModal';
|
|||||||
import { MoveCopyModal } from './components/Modals/MoveCopyModal';
|
import { MoveCopyModal } from './components/Modals/MoveCopyModal';
|
||||||
import type { ViewMode } from './types';
|
import type { ViewMode } from './types';
|
||||||
import { vfsApi, type VfsEntry } from '../../api/client';
|
import { vfsApi, type VfsEntry } from '../../api/client';
|
||||||
|
import { LoadingSkeleton } from './components/LoadingSkeleton';
|
||||||
|
|
||||||
const FileExplorerPage = memo(function FileExplorerPage() {
|
const FileExplorerPage = memo(function FileExplorerPage() {
|
||||||
const { navKey = 'files', '*': restPath = '' } = useParams();
|
const { navKey = 'files', '*': restPath = '' } = useParams();
|
||||||
const { token } = theme.useToken();
|
const { token } = theme.useToken();
|
||||||
const [viewMode, setViewMode] = useState<ViewMode>('grid');
|
const [viewMode, setViewMode] = useState<ViewMode>('grid');
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const [showSkeleton, setShowSkeleton] = useState(false);
|
||||||
const dragCounter = useRef(0);
|
const dragCounter = useRef(0);
|
||||||
|
const skeletonTimerRef = useRef<number | null>(null);
|
||||||
|
|
||||||
// --- Hooks ---
|
// --- Hooks ---
|
||||||
const { path, entries, loading, pagination, processorTypes, sortBy, sortOrder, load, navigateTo, goUp, handlePaginationChange, refresh, handleSortChange } = useFileExplorer(navKey);
|
const { path, entries, loading, pagination, processorTypes, sortBy, sortOrder, load, navigateTo, goUp, handlePaginationChange, refresh, handleSortChange } = useFileExplorer(navKey);
|
||||||
@@ -40,7 +43,7 @@ const FileExplorerPage = memo(function FileExplorerPage() {
|
|||||||
const { openFileWithDefaultApp, confirmOpenWithApp } = useAppWindows();
|
const { openFileWithDefaultApp, confirmOpenWithApp } = useAppWindows();
|
||||||
const { ctxMenu, blankCtxMenu, openContextMenu, openBlankContextMenu, closeContextMenus } = useContextMenu();
|
const { ctxMenu, blankCtxMenu, openContextMenu, openBlankContextMenu, closeContextMenus } = useContextMenu();
|
||||||
const uploader = useUploader(path, refresh);
|
const uploader = useUploader(path, refresh);
|
||||||
const { handleFileDrop } = uploader;
|
const { handleFileDrop, openFilePicker, openDirectoryPicker, handleFileInputChange, handleDirectoryInputChange } = uploader;
|
||||||
const processorHook = useProcessor({ path, processorTypes, refresh });
|
const processorHook = useProcessor({ path, processorTypes, refresh });
|
||||||
const { thumbs } = useThumbnails(entries, path);
|
const { thumbs } = useThumbnails(entries, path);
|
||||||
|
|
||||||
@@ -50,16 +53,40 @@ const FileExplorerPage = memo(function FileExplorerPage() {
|
|||||||
const [sharingEntries, setSharingEntries] = useState<VfsEntry[]>([]);
|
const [sharingEntries, setSharingEntries] = useState<VfsEntry[]>([]);
|
||||||
const [detailEntry, setDetailEntry] = useState<VfsEntry | null>(null);
|
const [detailEntry, setDetailEntry] = useState<VfsEntry | null>(null);
|
||||||
const [directLinkEntry, setDirectLinkEntry] = useState<VfsEntry | null>(null);
|
const [directLinkEntry, setDirectLinkEntry] = useState<VfsEntry | null>(null);
|
||||||
const [detailData, setDetailData] = useState<any>(null);
|
const [detailData, setDetailData] = useState<Record<string, unknown> | { error: string } | null>(null);
|
||||||
const [detailLoading, setDetailLoading] = useState(false);
|
const [detailLoading, setDetailLoading] = useState(false);
|
||||||
const [movingEntries, setMovingEntries] = useState<VfsEntry[]>([]);
|
const [movingEntries, setMovingEntries] = useState<VfsEntry[]>([]);
|
||||||
const [copyingEntries, setCopyingEntries] = useState<VfsEntry[]>([]);
|
const [copyingEntries, setCopyingEntries] = useState<VfsEntry[]>([]);
|
||||||
|
|
||||||
// --- Effects ---
|
// --- Effects ---
|
||||||
|
const routePath = '/' + (restPath || '').replace(/^\/+/, '');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const routeP = '/' + (restPath || '').replace(/^\/+/, '');
|
load(routePath, 1, pagination.pageSize, sortBy, sortOrder);
|
||||||
load(routeP, 1, pagination.pageSize, sortBy, sortOrder);
|
}, [routePath, navKey, load, pagination.pageSize, sortBy, sortOrder]);
|
||||||
}, [restPath, 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 ---
|
// --- Handlers ---
|
||||||
const handleOpenEntry = (entry: VfsEntry) => {
|
const handleOpenEntry = (entry: VfsEntry) => {
|
||||||
@@ -77,9 +104,10 @@ const FileExplorerPage = memo(function FileExplorerPage() {
|
|||||||
try {
|
try {
|
||||||
const fullPath = (path === '/' ? '' : path) + '/' + entry.name;
|
const fullPath = (path === '/' ? '' : path) + '/' + entry.name;
|
||||||
const stat = await vfsApi.stat(fullPath);
|
const stat = await vfsApi.stat(fullPath);
|
||||||
setDetailData(stat);
|
setDetailData(stat as Record<string, unknown>);
|
||||||
} catch (e: any) {
|
} catch (error) {
|
||||||
setDetailData({ error: e.message });
|
const messageText = error instanceof Error ? error.message : String(error);
|
||||||
|
setDetailData({ error: messageText });
|
||||||
} finally {
|
} finally {
|
||||||
setDetailLoading(false);
|
setDetailLoading(false);
|
||||||
}
|
}
|
||||||
@@ -128,7 +156,7 @@ const FileExplorerPage = memo(function FileExplorerPage() {
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setIsDragging(false);
|
setIsDragging(false);
|
||||||
dragCounter.current = 0;
|
dragCounter.current = 0;
|
||||||
handleFileDrop(e.dataTransfer.files);
|
void handleFileDrop(e.dataTransfer);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -159,22 +187,37 @@ const FileExplorerPage = memo(function FileExplorerPage() {
|
|||||||
onNavigate={navigateTo}
|
onNavigate={navigateTo}
|
||||||
onRefresh={refresh}
|
onRefresh={refresh}
|
||||||
onCreateDir={() => setCreatingDir(true)}
|
onCreateDir={() => setCreatingDir(true)}
|
||||||
onUpload={uploader.openModal}
|
onUploadFile={openFilePicker}
|
||||||
|
onUploadDirectory={openDirectoryPicker}
|
||||||
onSetViewMode={setViewMode}
|
onSetViewMode={setViewMode}
|
||||||
onSortChange={handleSortChange}
|
onSortChange={handleSortChange}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<input ref={uploader.fileInputRef} type="file" style={{ display: 'none' }} multiple onChange={uploader.handleFileChange} />
|
<input
|
||||||
|
ref={uploader.fileInputRef}
|
||||||
|
type="file"
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
multiple
|
||||||
|
onChange={handleFileInputChange}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
ref={uploader.directoryInputRef}
|
||||||
|
type="file"
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
multiple
|
||||||
|
onChange={handleDirectoryInputChange}
|
||||||
|
/>
|
||||||
|
|
||||||
<div style={{ flex: 1, overflow: 'auto', paddingBottom: pagination.total > 0 ? '80px' : '0' }} onContextMenu={openBlankContextMenu}>
|
<div style={{ flex: 1, overflow: 'auto', paddingBottom: pagination.total > 0 ? '80px' : '0' }} onContextMenu={openBlankContextMenu}>
|
||||||
{loading && entries.length === 0 ? (
|
{showSkeleton && loading && (entries.length === 0 || path !== routePath) ? (
|
||||||
|
<LoadingSkeleton mode={viewMode} />
|
||||||
|
) : !loading && entries.length === 0 ? (
|
||||||
<div style={{ textAlign: 'center', padding: 40 }}><EmptyState isRoot={path === '/'} /></div>
|
<div style={{ textAlign: 'center', padding: 40 }}><EmptyState isRoot={path === '/'} /></div>
|
||||||
) : viewMode === 'grid' ? (
|
) : viewMode === 'grid' ? (
|
||||||
<GridView
|
<GridView
|
||||||
entries={entries}
|
entries={entries}
|
||||||
thumbs={thumbs}
|
thumbs={thumbs}
|
||||||
selectedEntries={selectedEntries}
|
selectedEntries={selectedEntries}
|
||||||
loading={loading}
|
|
||||||
path={path}
|
path={path}
|
||||||
onSelect={handleSelect}
|
onSelect={handleSelect}
|
||||||
onSelectRange={handleSelectRange}
|
onSelectRange={handleSelectRange}
|
||||||
@@ -184,7 +227,6 @@ const FileExplorerPage = memo(function FileExplorerPage() {
|
|||||||
) : (
|
) : (
|
||||||
<FileListView
|
<FileListView
|
||||||
entries={entries}
|
entries={entries}
|
||||||
loading={loading}
|
|
||||||
selectedEntries={selectedEntries}
|
selectedEntries={selectedEntries}
|
||||||
onRowClick={(r, e) => handleSelect(r, e.ctrlKey || e.metaKey)}
|
onRowClick={(r, e) => handleSelect(r, e.ctrlKey || e.metaKey)}
|
||||||
onSelectionChange={setSelectedEntries}
|
onSelectionChange={setSelectedEntries}
|
||||||
@@ -282,7 +324,8 @@ const FileExplorerPage = memo(function FileExplorerPage() {
|
|||||||
processorHook.setSelectedProcessor(type);
|
processorHook.setSelectedProcessor(type);
|
||||||
processorHook.openProcessorModal(entry);
|
processorHook.openProcessorModal(entry);
|
||||||
}}
|
}}
|
||||||
onUpload={uploader.openModal}
|
onUploadFile={openFilePicker}
|
||||||
|
onUploadDirectory={openDirectoryPicker}
|
||||||
onCreateDir={() => setCreatingDir(true)}
|
onCreateDir={() => setCreatingDir(true)}
|
||||||
onShare={doShare}
|
onShare={doShare}
|
||||||
onGetDirectLink={doGetDirectLink}
|
onGetDirectLink={doGetDirectLink}
|
||||||
@@ -293,8 +336,14 @@ const FileExplorerPage = memo(function FileExplorerPage() {
|
|||||||
<UploadModal
|
<UploadModal
|
||||||
visible={uploader.isModalVisible}
|
visible={uploader.isModalVisible}
|
||||||
files={uploader.files}
|
files={uploader.files}
|
||||||
|
isUploading={uploader.isUploading}
|
||||||
|
totalProgress={uploader.totalProgress}
|
||||||
|
totalFileBytes={uploader.totalFileBytes}
|
||||||
|
uploadedFileBytes={uploader.uploadedFileBytes}
|
||||||
|
conflict={uploader.conflict}
|
||||||
onClose={uploader.closeModal}
|
onClose={uploader.closeModal}
|
||||||
onStartUpload={uploader.startUpload}
|
onStartUpload={uploader.startUpload}
|
||||||
|
onResolveConflict={uploader.confirmConflict}
|
||||||
/>
|
/>
|
||||||
<DropzoneOverlay visible={isDragging} />
|
<DropzoneOverlay visible={isDragging} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import React, { useLayoutEffect, useRef, useState } from 'react';
|
import React, { useLayoutEffect, useRef, useState } from 'react';
|
||||||
import { Menu, theme } from 'antd';
|
import { Menu, theme } from 'antd';
|
||||||
|
import type { MenuProps } from 'antd';
|
||||||
import type { VfsEntry } from '../../../api/client';
|
import type { VfsEntry } from '../../../api/client';
|
||||||
|
import type { ProcessorTypeMeta } from '../../../api/processors';
|
||||||
import { getAppsForEntry, getDefaultAppForEntry } from '../../../apps/registry';
|
import { getAppsForEntry, getDefaultAppForEntry } from '../../../apps/registry';
|
||||||
import { useI18n } from '../../../i18n';
|
import { useI18n } from '../../../i18n';
|
||||||
import {
|
import {
|
||||||
@@ -15,7 +17,7 @@ interface ContextMenuProps {
|
|||||||
entry?: VfsEntry;
|
entry?: VfsEntry;
|
||||||
entries: VfsEntry[];
|
entries: VfsEntry[];
|
||||||
selectedEntries: string[];
|
selectedEntries: string[];
|
||||||
processorTypes: any[];
|
processorTypes: ProcessorTypeMeta[];
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onOpen: (entry: VfsEntry) => void;
|
onOpen: (entry: VfsEntry) => void;
|
||||||
onOpenWith: (entry: VfsEntry, appKey: string) => void;
|
onOpenWith: (entry: VfsEntry, appKey: string) => void;
|
||||||
@@ -24,7 +26,8 @@ interface ContextMenuProps {
|
|||||||
onDelete: (entries: VfsEntry[]) => void;
|
onDelete: (entries: VfsEntry[]) => void;
|
||||||
onDetail: (entry: VfsEntry) => void;
|
onDetail: (entry: VfsEntry) => void;
|
||||||
onProcess: (entry: VfsEntry, processorType: string) => void;
|
onProcess: (entry: VfsEntry, processorType: string) => void;
|
||||||
onUpload: () => void;
|
onUploadFile: () => void;
|
||||||
|
onUploadDirectory: () => void;
|
||||||
onCreateDir: () => void;
|
onCreateDir: () => void;
|
||||||
onShare: (entries: VfsEntry[]) => void;
|
onShare: (entries: VfsEntry[]) => void;
|
||||||
onGetDirectLink: (entry: VfsEntry) => void;
|
onGetDirectLink: (entry: VfsEntry) => void;
|
||||||
@@ -32,6 +35,18 @@ interface ContextMenuProps {
|
|||||||
onCopy: (entries: VfsEntry[]) => void;
|
onCopy: (entries: VfsEntry[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type MenuItem = Required<MenuProps>['items'][number];
|
||||||
|
|
||||||
|
interface ActionMenuItem {
|
||||||
|
key: string;
|
||||||
|
label: React.ReactNode;
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
disabled?: boolean;
|
||||||
|
danger?: boolean;
|
||||||
|
onClick?: () => void;
|
||||||
|
children?: ActionMenuItem[];
|
||||||
|
}
|
||||||
|
|
||||||
export const ContextMenu: React.FC<ContextMenuProps> = (props) => {
|
export const ContextMenu: React.FC<ContextMenuProps> = (props) => {
|
||||||
const { token } = theme.useToken();
|
const { token } = theme.useToken();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
@@ -43,10 +58,18 @@ export const ContextMenu: React.FC<ContextMenuProps> = (props) => {
|
|||||||
setPosition({ left: x, top: y });
|
setPosition({ left: x, top: y });
|
||||||
}, [x, y]);
|
}, [x, y]);
|
||||||
|
|
||||||
const getContextMenuItems = () => {
|
const getContextMenuItems = (): ActionMenuItem[] => {
|
||||||
if (!entry) { // Blank context menu
|
if (!entry) { // Blank context menu
|
||||||
return [
|
return [
|
||||||
{ key: 'upload', label: t('Upload File'), icon: <UploadOutlined />, onClick: actions.onUpload },
|
{
|
||||||
|
key: 'upload',
|
||||||
|
label: t('Upload'),
|
||||||
|
icon: <UploadOutlined />,
|
||||||
|
children: [
|
||||||
|
{ key: 'upload-file', label: t('Upload Files'), onClick: actions.onUploadFile },
|
||||||
|
{ key: 'upload-folder', label: t('Upload Folder'), onClick: actions.onUploadDirectory },
|
||||||
|
],
|
||||||
|
},
|
||||||
{ key: 'mkdir', label: t('New Folder'), icon: <PlusOutlined />, onClick: actions.onCreateDir },
|
{ key: 'mkdir', label: t('New Folder'), icon: <PlusOutlined />, onClick: actions.onCreateDir },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -57,7 +80,7 @@ export const ContextMenu: React.FC<ContextMenuProps> = (props) => {
|
|||||||
const targetNames = selectedEntries.includes(entry.name) ? selectedEntries : [entry.name];
|
const targetNames = selectedEntries.includes(entry.name) ? selectedEntries : [entry.name];
|
||||||
const targetEntries = entries.filter(e => targetNames.includes(e.name));
|
const targetEntries = entries.filter(e => targetNames.includes(e.name));
|
||||||
|
|
||||||
let processorSubMenu: any[] = [];
|
let processorSubMenu: ActionMenuItem[] = [];
|
||||||
if (!entry.is_dir && processorTypes.length > 0) {
|
if (!entry.is_dir && processorTypes.length > 0) {
|
||||||
const ext = entry.name.split('.').pop()?.toLowerCase() || '';
|
const ext = entry.name.split('.').pop()?.toLowerCase() || '';
|
||||||
processorSubMenu = processorTypes
|
processorSubMenu = processorTypes
|
||||||
@@ -73,7 +96,7 @@ export const ContextMenu: React.FC<ContextMenuProps> = (props) => {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
const menuItems: (ActionMenuItem | null)[] = [
|
||||||
(entry.is_dir || apps.length > 0) ? {
|
(entry.is_dir || apps.length > 0) ? {
|
||||||
key: 'open',
|
key: 'open',
|
||||||
label: defaultApp ? `${t('Open')} (${defaultApp.name})` : t('Open'),
|
label: defaultApp ? `${t('Open')} (${defaultApp.name})` : t('Open'),
|
||||||
@@ -151,18 +174,32 @@ export const ContextMenu: React.FC<ContextMenuProps> = (props) => {
|
|||||||
icon: <InfoCircleOutlined />,
|
icon: <InfoCircleOutlined />,
|
||||||
onClick: () => actions.onDetail(entry),
|
onClick: () => actions.onDetail(entry),
|
||||||
},
|
},
|
||||||
].filter(Boolean);
|
];
|
||||||
|
|
||||||
|
return menuItems.filter((item): item is ActionMenuItem => item !== null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const items = getContextMenuItems()
|
const actionItems = getContextMenuItems();
|
||||||
.filter(item => item !== null) // Ensure no null items
|
|
||||||
.map(item => ({
|
const handlerMap = new Map<string, () => void>();
|
||||||
...item,
|
|
||||||
onClick: () => {
|
const mapItems = (source: ActionMenuItem[]): MenuItem[] =>
|
||||||
if (item.onClick) item.onClick();
|
source.map<MenuItem>((item) => {
|
||||||
onClose();
|
if (item.onClick) handlerMap.set(item.key, item.onClick);
|
||||||
}
|
const mappedChildren = item.children && item.children.length > 0 ? mapItems(item.children) : undefined;
|
||||||
}));
|
|
||||||
|
const transformed = {
|
||||||
|
key: item.key,
|
||||||
|
label: item.label,
|
||||||
|
icon: item.icon,
|
||||||
|
disabled: item.disabled,
|
||||||
|
danger: item.danger,
|
||||||
|
...(mappedChildren ? { children: mappedChildren } : {}),
|
||||||
|
} as MenuItem;
|
||||||
|
return transformed;
|
||||||
|
});
|
||||||
|
|
||||||
|
const items = mapItems(actionItems);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (typeof window === 'undefined') return;
|
if (typeof window === 'undefined') return;
|
||||||
@@ -203,8 +240,13 @@ export const ContextMenu: React.FC<ContextMenuProps> = (props) => {
|
|||||||
onClick={onClose} // Close on any click inside the menu area
|
onClick={onClose} // Close on any click inside the menu area
|
||||||
>
|
>
|
||||||
<Menu
|
<Menu
|
||||||
items={items as any[]}
|
items={items}
|
||||||
selectable={false}
|
selectable={false}
|
||||||
|
onClick={({ key }) => {
|
||||||
|
const handler = handlerMap.get(String(key));
|
||||||
|
if (handler) handler();
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
style={{ width: 160, borderRadius: token.borderRadius, background: 'transparent' }}
|
style={{ width: 160, borderRadius: token.borderRadius, background: 'transparent' }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import { useI18n } from '../../../i18n';
|
|||||||
|
|
||||||
interface FileListViewProps {
|
interface FileListViewProps {
|
||||||
entries: VfsEntry[];
|
entries: VfsEntry[];
|
||||||
loading: boolean;
|
|
||||||
selectedEntries: string[];
|
selectedEntries: string[];
|
||||||
onRowClick: (entry: VfsEntry, e: React.MouseEvent) => void;
|
onRowClick: (entry: VfsEntry, e: React.MouseEvent) => void;
|
||||||
onSelectionChange: (selectedKeys: string[]) => void;
|
onSelectionChange: (selectedKeys: string[]) => void;
|
||||||
@@ -22,7 +21,6 @@ interface FileListViewProps {
|
|||||||
|
|
||||||
export const FileListView: React.FC<FileListViewProps> = ({
|
export const FileListView: React.FC<FileListViewProps> = ({
|
||||||
entries,
|
entries,
|
||||||
loading,
|
|
||||||
selectedEntries,
|
selectedEntries,
|
||||||
onRowClick,
|
onRowClick,
|
||||||
onSelectionChange,
|
onSelectionChange,
|
||||||
@@ -107,7 +105,6 @@ export const FileListView: React.FC<FileListViewProps> = ({
|
|||||||
rowKey={r => r.name}
|
rowKey={r => r.name}
|
||||||
dataSource={entries}
|
dataSource={entries}
|
||||||
columns={columns as any}
|
columns={columns as any}
|
||||||
loading={loading}
|
|
||||||
pagination={false}
|
pagination={false}
|
||||||
onRow={(r) => ({
|
onRow={(r) => ({
|
||||||
onClick: (e: any) => onRowClick(r, e),
|
onClick: (e: any) => onRowClick(r, e),
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useRef, useState, useEffect } from 'react';
|
import React, { useRef, useState, useEffect } from 'react';
|
||||||
import { Tooltip, Spin, theme } from 'antd';
|
import { Tooltip, theme } from 'antd';
|
||||||
import { FolderFilled, PictureOutlined } from '@ant-design/icons';
|
import { FolderFilled, PictureOutlined } from '@ant-design/icons';
|
||||||
import type { VfsEntry } from '../../../api/client';
|
import type { VfsEntry } from '../../../api/client';
|
||||||
import { getFileIcon } from './FileIcons';
|
import { getFileIcon } from './FileIcons';
|
||||||
@@ -10,7 +10,6 @@ interface Props {
|
|||||||
entries: VfsEntry[];
|
entries: VfsEntry[];
|
||||||
thumbs: Record<string, string>;
|
thumbs: Record<string, string>;
|
||||||
selectedEntries: string[];
|
selectedEntries: string[];
|
||||||
loading: boolean;
|
|
||||||
path: string;
|
path: string;
|
||||||
onSelect: (e: VfsEntry, additive?: boolean) => void;
|
onSelect: (e: VfsEntry, additive?: boolean) => void;
|
||||||
onSelectRange: (names: string[]) => void;
|
onSelectRange: (names: string[]) => void;
|
||||||
@@ -25,7 +24,7 @@ const formatSize = (size: number) => {
|
|||||||
return (size / 1024 / 1024 / 1024).toFixed(1) + ' GB';
|
return (size / 1024 / 1024 / 1024).toFixed(1) + ' GB';
|
||||||
};
|
};
|
||||||
|
|
||||||
export const GridView: React.FC<Props> = ({ entries, thumbs, selectedEntries, loading, path, onSelect, onSelectRange, onOpen, onContextMenu }) => {
|
export const GridView: React.FC<Props> = ({ entries, thumbs, selectedEntries, path, onSelect, onSelectRange, onOpen, onContextMenu }) => {
|
||||||
const { token } = theme.useToken();
|
const { token } = theme.useToken();
|
||||||
const { resolvedMode } = useTheme();
|
const { resolvedMode } = useTheme();
|
||||||
const lightenColor = (hex: string, amount: number) => {
|
const lightenColor = (hex: string, amount: number) => {
|
||||||
@@ -185,8 +184,7 @@ export const GridView: React.FC<Props> = ({ entries, thumbs, selectedEntries, lo
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{loading && <div style={{ width: '100%', textAlign: 'center', padding: 40 }}><Spin /></div>}
|
{entries.length === 0 && <EmptyState isRoot={path === '/'} />}
|
||||||
{!loading && entries.length === 0 && <EmptyState isRoot={path === '/'} />}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Flex, Typography, Divider, Button, Space, Tooltip, Segmented, Breadcrumb, Input, theme } from 'antd';
|
import { Flex, Typography, Divider, Button, Space, Tooltip, Segmented, Breadcrumb, Input, theme, Dropdown } from 'antd';
|
||||||
import { ArrowUpOutlined, ArrowDownOutlined, ReloadOutlined, PlusOutlined, UploadOutlined, AppstoreOutlined, UnorderedListOutlined } from '@ant-design/icons';
|
import { ArrowUpOutlined, ArrowDownOutlined, ReloadOutlined, PlusOutlined, UploadOutlined, AppstoreOutlined, UnorderedListOutlined } from '@ant-design/icons';
|
||||||
import { Select } from 'antd';
|
import { Select } from 'antd';
|
||||||
import { useI18n } from '../../../i18n';
|
import { useI18n } from '../../../i18n';
|
||||||
@@ -16,7 +16,8 @@ interface HeaderProps {
|
|||||||
onNavigate: (path: string) => void;
|
onNavigate: (path: string) => void;
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
onCreateDir: () => void;
|
onCreateDir: () => void;
|
||||||
onUpload: () => void;
|
onUploadFile: () => void;
|
||||||
|
onUploadDirectory: () => void;
|
||||||
onSetViewMode: (mode: ViewMode) => void;
|
onSetViewMode: (mode: ViewMode) => void;
|
||||||
onSortChange: (sortBy: string, sortOrder: string) => void;
|
onSortChange: (sortBy: string, sortOrder: string) => void;
|
||||||
}
|
}
|
||||||
@@ -31,7 +32,8 @@ export const Header: React.FC<HeaderProps> = ({
|
|||||||
onNavigate,
|
onNavigate,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
onCreateDir,
|
onCreateDir,
|
||||||
onUpload,
|
onUploadFile,
|
||||||
|
onUploadDirectory,
|
||||||
onSetViewMode,
|
onSetViewMode,
|
||||||
onSortChange,
|
onSortChange,
|
||||||
}) => {
|
}) => {
|
||||||
@@ -108,7 +110,26 @@ export const Header: React.FC<HeaderProps> = ({
|
|||||||
<Space size={8} wrap>
|
<Space size={8} wrap>
|
||||||
<Button size="small" icon={<ReloadOutlined />} onClick={onRefresh} loading={loading}>{t('Refresh')}</Button>
|
<Button size="small" icon={<ReloadOutlined />} onClick={onRefresh} loading={loading}>{t('Refresh')}</Button>
|
||||||
<Button size="small" icon={<PlusOutlined />} onClick={onCreateDir}>{t('New Folder')}</Button>
|
<Button size="small" icon={<PlusOutlined />} onClick={onCreateDir}>{t('New Folder')}</Button>
|
||||||
<Button size="small" icon={<UploadOutlined />} onClick={onUpload}>{t('Upload')}</Button>
|
<Dropdown.Button
|
||||||
|
size="small"
|
||||||
|
icon={<UploadOutlined />}
|
||||||
|
onClick={onUploadFile}
|
||||||
|
menu={{
|
||||||
|
items: [
|
||||||
|
{ key: 'file', label: t('Upload Files') },
|
||||||
|
{ key: 'folder', label: t('Upload Folder') },
|
||||||
|
],
|
||||||
|
onClick: ({ key }) => {
|
||||||
|
if (key === 'folder') {
|
||||||
|
onUploadDirectory();
|
||||||
|
} else {
|
||||||
|
onUploadFile();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('Upload')}
|
||||||
|
</Dropdown.Button>
|
||||||
<Select
|
<Select
|
||||||
size="small"
|
size="small"
|
||||||
value={sortBy}
|
value={sortBy}
|
||||||
@@ -128,7 +149,7 @@ export const Header: React.FC<HeaderProps> = ({
|
|||||||
<Segmented
|
<Segmented
|
||||||
size="small"
|
size="small"
|
||||||
value={viewMode}
|
value={viewMode}
|
||||||
onChange={v => onSetViewMode(v as any)}
|
onChange={value => onSetViewMode(value as ViewMode)}
|
||||||
options={[
|
options={[
|
||||||
{ label: <Tooltip title={t('Grid')}><AppstoreOutlined /></Tooltip>, value: 'grid' },
|
{ label: <Tooltip title={t('Grid')}><AppstoreOutlined /></Tooltip>, value: 'grid' },
|
||||||
{ label: <Tooltip title={t('List')}><UnorderedListOutlined /></Tooltip>, value: 'list' }
|
{ label: <Tooltip title={t('List')}><UnorderedListOutlined /></Tooltip>, value: 'list' }
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import type { FC } from 'react';
|
||||||
|
import { Skeleton, theme } from 'antd';
|
||||||
|
|
||||||
|
type LoadingMode = 'grid' | 'list';
|
||||||
|
|
||||||
|
interface LoadingSkeletonProps {
|
||||||
|
mode: LoadingMode;
|
||||||
|
count?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const createArray = (length: number) => Array.from({ length }, (_, index) => index);
|
||||||
|
|
||||||
|
export const LoadingSkeleton: FC<LoadingSkeletonProps> = ({ mode, count }) => {
|
||||||
|
const { token } = theme.useToken();
|
||||||
|
const fallbackCount = mode === 'grid' ? 50 : 30;
|
||||||
|
const items = createArray(count ?? fallbackCount);
|
||||||
|
|
||||||
|
if (mode === 'grid') {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fill, minmax(160px, 1fr))',
|
||||||
|
gap: 16,
|
||||||
|
padding: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{items.map((key) => (
|
||||||
|
<div
|
||||||
|
key={key}
|
||||||
|
style={{
|
||||||
|
background: token.colorBgElevated,
|
||||||
|
borderRadius: token.borderRadius,
|
||||||
|
padding: 16,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Skeleton.Button active block style={{ height: 96, borderRadius: token.borderRadiusLG }} />
|
||||||
|
<Skeleton active title={false} paragraph={{ rows: 2, width: ['80%', '60%'] }} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '0 16px' }}>
|
||||||
|
{items.map((key) => (
|
||||||
|
<div
|
||||||
|
key={key}
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: '48px 1fr',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: '12px 16px',
|
||||||
|
borderBottom: `1px solid ${token.colorBorderSecondary}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Skeleton.Avatar active shape="square" size={32} />
|
||||||
|
<div style={{ paddingLeft: 16 }}>
|
||||||
|
<Skeleton active title={false} paragraph={{ rows: 1, width: '60%' }} />
|
||||||
|
<Skeleton active title={false} paragraph={{ rows: 1, width: '40%' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
@@ -1,24 +1,57 @@
|
|||||||
import React, { useEffect } from 'react';
|
import React, { useEffect, useMemo } from 'react';
|
||||||
import { Modal, Button, List, Progress, Typography, message, Flex } from 'antd';
|
import { Modal, Button, List, Progress, Typography, message, Flex, Tag, Space } from 'antd';
|
||||||
import { CopyOutlined, CheckCircleFilled, CloseCircleFilled } from '@ant-design/icons';
|
import { CopyOutlined, CheckCircleFilled, CloseCircleFilled } from '@ant-design/icons';
|
||||||
import type { UploadFile } from '../../hooks/useUploader';
|
import type { ConflictDecision, UploadConflict, UploadFile } from '../../hooks/useUploader';
|
||||||
import { useI18n } from '../../../../i18n';
|
import { useI18n } from '../../../../i18n';
|
||||||
|
|
||||||
interface UploadModalProps {
|
interface UploadModalProps {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
files: UploadFile[];
|
files: UploadFile[];
|
||||||
|
isUploading: boolean;
|
||||||
|
totalProgress: number;
|
||||||
|
totalFileBytes: number;
|
||||||
|
uploadedFileBytes: number;
|
||||||
|
conflict: UploadConflict | null;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onStartUpload: () => void;
|
onStartUpload: () => void;
|
||||||
|
onResolveConflict: (decision: ConflictDecision) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const UploadModal: React.FC<UploadModalProps> = ({ visible, files, onClose, onStartUpload }) => {
|
const formatBytes = (bytes: number) => {
|
||||||
|
if (bytes <= 0) return '0 B';
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||||
|
const index = Math.min(units.length - 1, Math.floor(Math.log(bytes) / Math.log(1024)));
|
||||||
|
const value = bytes / (1024 ** index);
|
||||||
|
return `${value.toFixed(value >= 10 || index === 0 ? 0 : 1)} ${units[index]}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const UploadModal: React.FC<UploadModalProps> = ({
|
||||||
|
visible,
|
||||||
|
files,
|
||||||
|
isUploading,
|
||||||
|
totalProgress,
|
||||||
|
totalFileBytes,
|
||||||
|
uploadedFileBytes,
|
||||||
|
conflict,
|
||||||
|
onClose,
|
||||||
|
onStartUpload,
|
||||||
|
onResolveConflict,
|
||||||
|
}) => {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
const allSuccess = files.every(f => f.status === 'success');
|
const summary = useMemo(() => {
|
||||||
|
const total = files.length;
|
||||||
|
const completed = files.filter(f => ['success', 'skipped'].includes(f.status)).length;
|
||||||
|
const failures = files.filter(f => f.status === 'error').length;
|
||||||
|
const pending = files.filter(f => ['pending', 'waiting', 'uploading'].includes(f.status)).length;
|
||||||
|
return { total, completed, failures, pending };
|
||||||
|
}, [files]);
|
||||||
|
|
||||||
|
const allFinished = files.length > 0 && files.every(f => ['success', 'error', 'skipped'].includes(f.status));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (visible && files.length > 0 && files.every(f => f.status === 'pending')) {
|
if (visible && files.length > 0 && files.some(f => f.status === 'pending')) {
|
||||||
onStartUpload();
|
onStartUpload();
|
||||||
}
|
}
|
||||||
}, [visible, files, onStartUpload]);
|
}, [visible, files, onStartUpload]);
|
||||||
|
|
||||||
@@ -28,6 +61,29 @@ const UploadModal: React.FC<UploadModalProps> = ({ visible, files, onClose, onSt
|
|||||||
};
|
};
|
||||||
|
|
||||||
const renderStatus = (file: UploadFile) => {
|
const renderStatus = (file: UploadFile) => {
|
||||||
|
if (file.type === 'directory') {
|
||||||
|
if (file.status === 'uploading') {
|
||||||
|
return <Typography.Text type="secondary">{t('Creating directory...')}</Typography.Text>;
|
||||||
|
}
|
||||||
|
if (file.status === 'success') {
|
||||||
|
return (
|
||||||
|
<Flex align="center" gap={8}>
|
||||||
|
<CheckCircleFilled style={{ color: 'var(--ant-color-success, #52c41a)' }} />
|
||||||
|
<Typography.Text type="secondary">{t('Directory ready')}</Typography.Text>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (file.status === 'error') {
|
||||||
|
return (
|
||||||
|
<Flex align="center" gap={8}>
|
||||||
|
<CloseCircleFilled style={{ color: 'var(--ant-color-error, #ff4d4f)' }} />
|
||||||
|
<Typography.Text type="danger" title={file.error}>{t('Create directory failed')}</Typography.Text>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <Typography.Text type="secondary">{t('Waiting to create')}</Typography.Text>;
|
||||||
|
}
|
||||||
|
|
||||||
switch (file.status) {
|
switch (file.status) {
|
||||||
case 'uploading':
|
case 'uploading':
|
||||||
return <Progress percent={Math.round(file.progress)} size="small" />;
|
return <Progress percent={Math.round(file.progress)} size="small" />;
|
||||||
@@ -39,6 +95,10 @@ const UploadModal: React.FC<UploadModalProps> = ({ visible, files, onClose, onSt
|
|||||||
<Button icon={<CopyOutlined />} size="small" onClick={() => handleCopy(file.permanentLink!)} type="text" />
|
<Button icon={<CopyOutlined />} size="small" onClick={() => handleCopy(file.permanentLink!)} type="text" />
|
||||||
</Flex>
|
</Flex>
|
||||||
);
|
);
|
||||||
|
case 'waiting':
|
||||||
|
return <Typography.Text type="warning">{t('Waiting for overwrite decision')}</Typography.Text>;
|
||||||
|
case 'skipped':
|
||||||
|
return <Typography.Text type="secondary">{t('Skipped')}</Typography.Text>;
|
||||||
case 'error':
|
case 'error':
|
||||||
return (
|
return (
|
||||||
<Flex align="center" gap={8}>
|
<Flex align="center" gap={8}>
|
||||||
@@ -56,13 +116,72 @@ const UploadModal: React.FC<UploadModalProps> = ({ visible, files, onClose, onSt
|
|||||||
open={visible}
|
open={visible}
|
||||||
title={t('Upload File')}
|
title={t('Upload File')}
|
||||||
width={600}
|
width={600}
|
||||||
|
closable={!isUploading}
|
||||||
|
maskClosable={!isUploading}
|
||||||
onCancel={onClose}
|
onCancel={onClose}
|
||||||
footer={[
|
footer={[
|
||||||
<Button key="close" onClick={onClose} disabled={!allSuccess && files.some(f => f.status === 'uploading')}>
|
<Button key="close" onClick={onClose} disabled={!allFinished || isUploading}>
|
||||||
{allSuccess ? t('Close') : t('Done')}
|
{allFinished ? t('Close') : t('Done')}
|
||||||
</Button>,
|
</Button>,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
|
<Space direction="vertical" style={{ width: '100%' }} size={16}>
|
||||||
|
<div>
|
||||||
|
<Flex justify="space-between" align="center">
|
||||||
|
<Typography.Text strong>
|
||||||
|
{t('Total progress')}:
|
||||||
|
</Typography.Text>
|
||||||
|
<Typography.Text type="secondary">
|
||||||
|
{t('Upload bytes summary', {
|
||||||
|
uploaded: formatBytes(uploadedFileBytes),
|
||||||
|
total: formatBytes(totalFileBytes),
|
||||||
|
})}
|
||||||
|
</Typography.Text>
|
||||||
|
</Flex>
|
||||||
|
<Progress percent={Math.round(totalProgress)} showInfo />
|
||||||
|
<Typography.Text type="secondary">
|
||||||
|
{t('Upload task summary', {
|
||||||
|
completed: summary.completed,
|
||||||
|
total: summary.total,
|
||||||
|
pending: summary.pending,
|
||||||
|
failures: summary.failures,
|
||||||
|
})}
|
||||||
|
</Typography.Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{conflict && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
border: '1px solid var(--ant-color-warning-border, #faad14)',
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: '12px 16px',
|
||||||
|
background: 'var(--ant-color-warning-bg, rgba(250,173,20,0.1))',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography.Text strong>
|
||||||
|
{t('Overwrite confirmation required')}
|
||||||
|
</Typography.Text>
|
||||||
|
<Typography.Paragraph type="secondary" style={{ marginBottom: 12 }}>
|
||||||
|
{t('Target already exists: {path}', { path: conflict.relativePath })}
|
||||||
|
</Typography.Paragraph>
|
||||||
|
<Flex gap={8} wrap="wrap">
|
||||||
|
<Button size="small" type="primary" onClick={() => onResolveConflict('overwrite')}>
|
||||||
|
{t('Overwrite')}
|
||||||
|
</Button>
|
||||||
|
<Button size="small" onClick={() => onResolveConflict('skip')}>
|
||||||
|
{t('Skip')}
|
||||||
|
</Button>
|
||||||
|
<Button size="small" type="primary" onClick={() => onResolveConflict('overwriteAll')}>
|
||||||
|
{t('Overwrite All')}
|
||||||
|
</Button>
|
||||||
|
<Button size="small" onClick={() => onResolveConflict('skipAll')}>
|
||||||
|
{t('Skip All')}
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
|
||||||
<List
|
<List
|
||||||
dataSource={files}
|
dataSource={files}
|
||||||
itemLayout="horizontal"
|
itemLayout="horizontal"
|
||||||
@@ -77,9 +196,16 @@ const UploadModal: React.FC<UploadModalProps> = ({ visible, files, onClose, onSt
|
|||||||
onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = 'transparent'; }}
|
onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = 'transparent'; }}
|
||||||
>
|
>
|
||||||
<Flex justify="space-between" align="center" style={{ width: '100%' }}>
|
<Flex justify="space-between" align="center" style={{ width: '100%' }}>
|
||||||
<Typography.Text ellipsis={{ tooltip: file.file.name }} style={{ maxWidth: '60%' }}>
|
<Flex align="center" gap={8} style={{ maxWidth: '60%', overflow: 'hidden' }}>
|
||||||
{file.file.name}
|
<Typography.Text ellipsis={{ tooltip: file.relativePath }} style={{ maxWidth: '100%' }}>
|
||||||
</Typography.Text>
|
{file.relativePath}
|
||||||
|
</Typography.Text>
|
||||||
|
{file.type === 'directory' ? (
|
||||||
|
<Tag color="blue">{t('Directory')}</Tag>
|
||||||
|
) : (
|
||||||
|
<Tag color="geekblue">{formatBytes(file.size)}</Tag>
|
||||||
|
)}
|
||||||
|
</Flex>
|
||||||
<div style={{ minWidth: 180, textAlign: 'right', flexShrink: 0 }}>
|
<div style={{ minWidth: 180, textAlign: 'right', flexShrink: 0 }}>
|
||||||
{renderStatus(file)}
|
{renderStatus(file)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export function useThumbnails(entries: VfsEntry[], path: string) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const newThumbs: Record<string, string> = {};
|
const newThumbs: Record<string, string> = {};
|
||||||
const targets = entries.filter(e => !e.is_dir && (e as any).is_image && !thumbs[e.name]);
|
const targets = entries.filter(e => !e.is_dir && (e as any).has_thumbnail && !thumbs[e.name]);
|
||||||
|
|
||||||
if (targets.length > 0) {
|
if (targets.length > 0) {
|
||||||
targets.forEach(ent => {
|
targets.forEach(ent => {
|
||||||
@@ -37,4 +37,4 @@ export function useThumbnails(entries: VfsEntry[], path: string) {
|
|||||||
}, [entries, path, thumbs]);
|
}, [entries, path, thumbs]);
|
||||||
|
|
||||||
return { thumbs };
|
return { thumbs };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,103 +1,592 @@
|
|||||||
import { useState, useCallback, useRef } from 'react';
|
import type { ChangeEvent, RefObject } from 'react';
|
||||||
|
import { useState, useCallback, useRef, useMemo, useEffect } from 'react';
|
||||||
|
import { message } from 'antd';
|
||||||
import { vfsApi } from '../../../api/client';
|
import { vfsApi } from '../../../api/client';
|
||||||
import { message }
|
import { useI18n } from '../../../i18n';
|
||||||
from 'antd';
|
|
||||||
|
type UploadStatus = 'pending' | 'waiting' | 'uploading' | 'success' | 'error' | 'skipped';
|
||||||
|
|
||||||
export interface UploadFile {
|
export interface UploadFile {
|
||||||
id: string;
|
id: string;
|
||||||
file: File;
|
name: string;
|
||||||
status: 'pending' | 'uploading' | 'success' | 'error';
|
relativePath: string;
|
||||||
|
targetPath: string;
|
||||||
|
type: 'file' | 'directory';
|
||||||
|
size: number;
|
||||||
|
loadedBytes: number;
|
||||||
|
status: UploadStatus;
|
||||||
progress: number;
|
progress: number;
|
||||||
error?: string;
|
error?: string;
|
||||||
permanentLink?: string;
|
permanentLink?: string;
|
||||||
|
file?: File;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ConflictDecision = 'overwrite' | 'skip' | 'overwriteAll' | 'skipAll';
|
||||||
|
|
||||||
|
export interface UploadConflict {
|
||||||
|
taskId: string;
|
||||||
|
relativePath: string;
|
||||||
|
targetPath: string;
|
||||||
|
type: 'file' | 'directory';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RawUploadFile {
|
||||||
|
kind: 'file';
|
||||||
|
relativePath: string;
|
||||||
|
file: File;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RawUploadDirectory {
|
||||||
|
kind: 'directory';
|
||||||
|
relativePath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type RawUploadItem = RawUploadFile | RawUploadDirectory;
|
||||||
|
|
||||||
|
const generateId = (() => {
|
||||||
|
const cryptoApi = typeof crypto !== 'undefined' ? crypto : undefined;
|
||||||
|
return () => {
|
||||||
|
if (cryptoApi?.randomUUID) return cryptoApi.randomUUID();
|
||||||
|
return `upload-${Date.now()}-${Math.random().toString(16).slice(2, 10)}`;
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
const normalizeRelativePath = (path: string) => path.replace(/\\/g, '/').replace(/^\/+/, '').replace(/\/+$/, '');
|
||||||
|
|
||||||
|
const joinWithBasePath = (base: string, relative: string) => {
|
||||||
|
const cleanedBase = base === '/' ? '' : base.replace(/\/+$/, '');
|
||||||
|
const cleanedRelative = normalizeRelativePath(relative);
|
||||||
|
const parts = [cleanedBase, cleanedRelative].filter(Boolean);
|
||||||
|
const joined = parts.join('/');
|
||||||
|
return joined.startsWith('/') ? joined : `/${joined}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const collectParentDirectories = (relativePath: string) => {
|
||||||
|
const normalized = normalizeRelativePath(relativePath);
|
||||||
|
if (!normalized) return [];
|
||||||
|
const segments = normalized.split('/').slice(0, -1);
|
||||||
|
const dirs: string[] = [];
|
||||||
|
for (let i = 1; i <= segments.length; i += 1) {
|
||||||
|
const dir = segments.slice(0, i).join('/');
|
||||||
|
if (dir) dirs.push(dir);
|
||||||
|
}
|
||||||
|
return dirs;
|
||||||
|
};
|
||||||
|
|
||||||
|
const collectAllDirectories = (items: RawUploadItem[]) => {
|
||||||
|
const directories = new Set<string>();
|
||||||
|
items.forEach((item) => {
|
||||||
|
if (item.kind === 'directory') {
|
||||||
|
const normalized = normalizeRelativePath(item.relativePath);
|
||||||
|
if (normalized) directories.add(normalized);
|
||||||
|
} else {
|
||||||
|
collectParentDirectories(item.relativePath).forEach((dir) => directories.add(dir));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return Array.from(directories).sort((a, b) => a.localeCompare(b));
|
||||||
|
};
|
||||||
|
|
||||||
|
interface WebkitFileSystemFileEntry {
|
||||||
|
isFile: true;
|
||||||
|
isDirectory: false;
|
||||||
|
name: string;
|
||||||
|
fullPath: string;
|
||||||
|
file: (
|
||||||
|
successCallback: (file: File) => void,
|
||||||
|
errorCallback?: (err: DOMException) => void,
|
||||||
|
) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WebkitFileSystemDirectoryReader {
|
||||||
|
readEntries: (
|
||||||
|
successCallback: (entries: WebkitFileSystemEntry[]) => void,
|
||||||
|
errorCallback?: (err: DOMException) => void,
|
||||||
|
) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WebkitFileSystemDirectoryEntry {
|
||||||
|
isFile: false;
|
||||||
|
isDirectory: true;
|
||||||
|
name: string;
|
||||||
|
fullPath: string;
|
||||||
|
createReader: () => WebkitFileSystemDirectoryReader;
|
||||||
|
}
|
||||||
|
|
||||||
|
type WebkitFileSystemEntry = WebkitFileSystemFileEntry | WebkitFileSystemDirectoryEntry;
|
||||||
|
|
||||||
|
const safeStat = async (fullPath: string): Promise<{ is_dir?: boolean } | null> => {
|
||||||
|
try {
|
||||||
|
return await vfsApi.stat(fullPath) as { is_dir?: boolean };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const readAllDirectoryEntries = (directoryEntry: WebkitFileSystemDirectoryEntry): Promise<WebkitFileSystemEntry[]> =>
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
|
const reader = directoryEntry.createReader();
|
||||||
|
const entries: WebkitFileSystemEntry[] = [];
|
||||||
|
const readBatch = () => {
|
||||||
|
reader.readEntries(
|
||||||
|
(batch: WebkitFileSystemEntry[]) => {
|
||||||
|
if (batch.length === 0) {
|
||||||
|
resolve(entries);
|
||||||
|
} else {
|
||||||
|
entries.push(...batch);
|
||||||
|
readBatch();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(err: DOMException) => reject(err),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
readBatch();
|
||||||
|
});
|
||||||
|
|
||||||
|
const traverseEntry = async (
|
||||||
|
entry: WebkitFileSystemEntry,
|
||||||
|
parentPath: string,
|
||||||
|
bucket: RawUploadItem[],
|
||||||
|
) => {
|
||||||
|
if (!entry) return;
|
||||||
|
const currentPath = parentPath ? `${parentPath}/${entry.name}` : entry.name;
|
||||||
|
if (entry.isFile) {
|
||||||
|
const file: File = await new Promise((resolve, reject) => {
|
||||||
|
entry.file(
|
||||||
|
(f: File) => resolve(f),
|
||||||
|
(err: DOMException) => reject(err),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
bucket.push({
|
||||||
|
kind: 'file',
|
||||||
|
relativePath: currentPath,
|
||||||
|
file,
|
||||||
|
});
|
||||||
|
} else if (entry.isDirectory) {
|
||||||
|
bucket.push({
|
||||||
|
kind: 'directory',
|
||||||
|
relativePath: currentPath,
|
||||||
|
});
|
||||||
|
const entries = await readAllDirectoryEntries(entry);
|
||||||
|
for (const child of entries) {
|
||||||
|
await traverseEntry(child, currentPath, bucket);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const collectFromFileList = async (list: FileList): Promise<RawUploadItem[]> => {
|
||||||
|
const items: RawUploadItem[] = [];
|
||||||
|
for (const file of Array.from(list)) {
|
||||||
|
const fileWithPath = file as File & { webkitRelativePath?: string };
|
||||||
|
const relativePath = fileWithPath.webkitRelativePath || file.name;
|
||||||
|
items.push({
|
||||||
|
kind: 'file',
|
||||||
|
relativePath,
|
||||||
|
file,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
};
|
||||||
|
|
||||||
|
const collectFromDataTransfer = async (dataTransfer: DataTransfer): Promise<RawUploadItem[]> => {
|
||||||
|
const items: RawUploadItem[] = [];
|
||||||
|
if (dataTransfer.items && dataTransfer.items.length > 0) {
|
||||||
|
for (const item of Array.from(dataTransfer.items)) {
|
||||||
|
const itemWithEntry = item as DataTransferItem & {
|
||||||
|
webkitGetAsEntry?: () => FileSystemEntry | null;
|
||||||
|
};
|
||||||
|
const entry = itemWithEntry.webkitGetAsEntry ? (itemWithEntry.webkitGetAsEntry() as unknown as WebkitFileSystemEntry) : null;
|
||||||
|
if (entry) {
|
||||||
|
await traverseEntry(entry, '', items);
|
||||||
|
} else if (item.kind === 'file') {
|
||||||
|
const file = item.getAsFile();
|
||||||
|
if (file) {
|
||||||
|
items.push({
|
||||||
|
kind: 'file',
|
||||||
|
relativePath: file.name,
|
||||||
|
file,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (dataTransfer.files && dataTransfer.files.length > 0) {
|
||||||
|
return collectFromFileList(dataTransfer.files);
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
};
|
||||||
|
|
||||||
|
const createUploadTasks = (basePath: string, items: RawUploadItem[]): UploadFile[] => {
|
||||||
|
const idGenerator = generateId;
|
||||||
|
const directories = collectAllDirectories(items);
|
||||||
|
const directoryTasks: UploadFile[] = directories.map((relativePath) => {
|
||||||
|
const targetPath = joinWithBasePath(basePath, relativePath);
|
||||||
|
const segments = normalizeRelativePath(relativePath).split('/');
|
||||||
|
const name = segments[segments.length - 1] || targetPath;
|
||||||
|
return {
|
||||||
|
id: idGenerator(),
|
||||||
|
name,
|
||||||
|
relativePath,
|
||||||
|
targetPath,
|
||||||
|
type: 'directory',
|
||||||
|
size: 0,
|
||||||
|
loadedBytes: 0,
|
||||||
|
status: 'pending',
|
||||||
|
progress: 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const fileTasks: UploadFile[] = items
|
||||||
|
.filter((item): item is RawUploadFile => item.kind === 'file')
|
||||||
|
.map((item) => {
|
||||||
|
const relativePath = normalizeRelativePath(item.relativePath) || item.file.name;
|
||||||
|
const targetPath = joinWithBasePath(basePath, relativePath);
|
||||||
|
return {
|
||||||
|
id: idGenerator(),
|
||||||
|
name: item.file.name,
|
||||||
|
relativePath,
|
||||||
|
targetPath,
|
||||||
|
type: 'file',
|
||||||
|
size: item.file.size,
|
||||||
|
loadedBytes: 0,
|
||||||
|
status: 'pending',
|
||||||
|
progress: 0,
|
||||||
|
file: item.file,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return [...directoryTasks, ...fileTasks];
|
||||||
|
};
|
||||||
|
|
||||||
export function useUploader(path: string, onUploadComplete: () => void) {
|
export function useUploader(path: string, onUploadComplete: () => void) {
|
||||||
|
const { t } = useI18n();
|
||||||
const [files, setFiles] = useState<UploadFile[]>([]);
|
const [files, setFiles] = useState<UploadFile[]>([]);
|
||||||
const [isModalVisible, setIsModalVisible] = useState(false);
|
const [isModalVisible, setIsModalVisible] = useState(false);
|
||||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
|
const [conflict, setConflict] = useState<UploadConflict | null>(null);
|
||||||
|
const conflictResolverRef = useRef<((decision: ConflictDecision) => void) | null>(null);
|
||||||
|
const overwriteAllRef = useRef(false);
|
||||||
|
const skipAllRef = useRef(false);
|
||||||
|
const createdDirsRef = useRef<Set<string>>(new Set());
|
||||||
|
const filesRef = useRef<UploadFile[]>(files);
|
||||||
|
const isUploadingRef = useRef(false);
|
||||||
|
|
||||||
const openModal = useCallback(() => {
|
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
const directoryInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const node = directoryInputRef.current;
|
||||||
|
if (!node) return;
|
||||||
|
node.setAttribute('webkitdirectory', '');
|
||||||
|
node.setAttribute('directory', '');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const mutateFiles = useCallback((updater: (prev: UploadFile[]) => UploadFile[]) => {
|
||||||
|
setFiles((prev) => {
|
||||||
|
const next = updater(prev);
|
||||||
|
filesRef.current = next;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const replaceFiles = useCallback((next: UploadFile[]) => {
|
||||||
|
filesRef.current = next;
|
||||||
|
setFiles(next);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const updateFile = useCallback((id: string, patch: Partial<UploadFile>) => {
|
||||||
|
mutateFiles((prev) => prev.map((f) => (f.id === id ? { ...f, ...patch } : f)));
|
||||||
|
}, [mutateFiles]);
|
||||||
|
|
||||||
|
const resetOverwriteDecisions = useCallback(() => {
|
||||||
|
overwriteAllRef.current = false;
|
||||||
|
skipAllRef.current = false;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const openFilePicker = useCallback(() => {
|
||||||
if (fileInputRef.current) {
|
if (fileInputRef.current) {
|
||||||
fileInputRef.current.click();
|
fileInputRef.current.click();
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const closeModal = useCallback(() => {
|
const openDirectoryPicker = useCallback(() => {
|
||||||
setIsModalVisible(false);
|
if (directoryInputRef.current) {
|
||||||
setFiles([]);
|
directoryInputRef.current.click();
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
const closeModal = useCallback(() => {
|
||||||
|
if (isUploadingRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsModalVisible(false);
|
||||||
|
replaceFiles([]);
|
||||||
|
resetOverwriteDecisions();
|
||||||
|
setConflict(null);
|
||||||
|
conflictResolverRef.current = null;
|
||||||
|
createdDirsRef.current = new Set();
|
||||||
|
}, [replaceFiles, resetOverwriteDecisions]);
|
||||||
|
|
||||||
|
const prepareQueue = useCallback((items: RawUploadItem[]) => {
|
||||||
|
if (!items.length) {
|
||||||
|
message.info(t('No items selected for upload'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const tasks = createUploadTasks(path, items);
|
||||||
|
if (!tasks.length) {
|
||||||
|
message.info(t('No uploadable files or directories found'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
replaceFiles(tasks);
|
||||||
|
resetOverwriteDecisions();
|
||||||
|
createdDirsRef.current = new Set();
|
||||||
|
setIsModalVisible(true);
|
||||||
|
}, [path, replaceFiles, resetOverwriteDecisions, t]);
|
||||||
|
|
||||||
|
const handleInputChange = useCallback(async (event: ChangeEvent<HTMLInputElement>, ref: RefObject<HTMLInputElement | null>) => {
|
||||||
const selectedFiles = event.target.files;
|
const selectedFiles = event.target.files;
|
||||||
if (selectedFiles && selectedFiles.length > 0) {
|
if (!selectedFiles || selectedFiles.length === 0) {
|
||||||
const newFiles: UploadFile[] = Array.from(selectedFiles).map(file => ({
|
return;
|
||||||
id: `${file.name}-${Date.now()}`,
|
}
|
||||||
file,
|
const items = await collectFromFileList(selectedFiles);
|
||||||
status: 'pending',
|
prepareQueue(items);
|
||||||
progress: 0,
|
if (ref.current) {
|
||||||
}));
|
ref.current.value = '';
|
||||||
setFiles(newFiles);
|
}
|
||||||
setIsModalVisible(true);
|
}, [prepareQueue]);
|
||||||
if (fileInputRef.current) {
|
|
||||||
fileInputRef.current.value = '';
|
const handleFileInputChange = useCallback(async (event: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
await handleInputChange(event, fileInputRef);
|
||||||
|
}, [handleInputChange]);
|
||||||
|
|
||||||
|
const handleDirectoryInputChange = useCallback(async (event: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
await handleInputChange(event, directoryInputRef);
|
||||||
|
}, [handleInputChange]);
|
||||||
|
|
||||||
|
const handleFileDrop = useCallback(async (data: DataTransfer) => {
|
||||||
|
const items = await collectFromDataTransfer(data);
|
||||||
|
prepareQueue(items);
|
||||||
|
}, [prepareQueue]);
|
||||||
|
|
||||||
|
const awaitConflictDecision = useCallback(async (task: UploadFile): Promise<'overwrite' | 'skip'> => {
|
||||||
|
if (overwriteAllRef.current) {
|
||||||
|
return 'overwrite';
|
||||||
|
}
|
||||||
|
if (skipAllRef.current) {
|
||||||
|
return 'skip';
|
||||||
|
}
|
||||||
|
return new Promise<'overwrite' | 'skip'>((resolve) => {
|
||||||
|
updateFile(task.id, { status: 'waiting' });
|
||||||
|
setConflict({
|
||||||
|
taskId: task.id,
|
||||||
|
relativePath: task.relativePath,
|
||||||
|
targetPath: task.targetPath,
|
||||||
|
type: task.type,
|
||||||
|
});
|
||||||
|
conflictResolverRef.current = (decision: ConflictDecision) => {
|
||||||
|
if (decision === 'overwriteAll') {
|
||||||
|
overwriteAllRef.current = true;
|
||||||
|
resolve('overwrite');
|
||||||
|
} else if (decision === 'skipAll') {
|
||||||
|
skipAllRef.current = true;
|
||||||
|
resolve('skip');
|
||||||
|
} else if (decision === 'overwrite') {
|
||||||
|
resolve('overwrite');
|
||||||
|
} else {
|
||||||
|
resolve('skip');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [updateFile]);
|
||||||
|
|
||||||
|
const confirmConflict = useCallback((decision: ConflictDecision) => {
|
||||||
|
if (!conflictResolverRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const resolver = conflictResolverRef.current;
|
||||||
|
conflictResolverRef.current = null;
|
||||||
|
setConflict(null);
|
||||||
|
resolver(decision);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const ensureDirectory = useCallback(async (fullPath: string) => {
|
||||||
|
const normalized = fullPath.replace(/\/+/g, '/');
|
||||||
|
if (!normalized || normalized === '/') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (createdDirsRef.current.has(normalized)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await vfsApi.mkdir(normalized);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const messageText = err instanceof Error ? err.message : String(err);
|
||||||
|
if (!/exist/i.test(messageText)) {
|
||||||
|
throw err;
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
createdDirsRef.current.add(normalized);
|
||||||
}
|
}
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const handleFileDrop = (droppedFiles: FileList) => {
|
const ensureDirectoryTree = useCallback(async (targetDir: string) => {
|
||||||
if (droppedFiles && droppedFiles.length > 0) {
|
if (!targetDir || targetDir === '/') return;
|
||||||
const newFiles: UploadFile[] = Array.from(droppedFiles).map(file => ({
|
const normalized = targetDir.replace(/\/+/g, '/');
|
||||||
id: `${file.name}-${Date.now()}`,
|
const segments = normalized.replace(/^\/+/, '').split('/').filter(Boolean);
|
||||||
file,
|
let current = '';
|
||||||
status: 'pending',
|
for (const segment of segments) {
|
||||||
progress: 0,
|
current = `${current}/${segment}`;
|
||||||
}));
|
await ensureDirectory(current.startsWith('/') ? current : `/${current}`);
|
||||||
setFiles(newFiles);
|
|
||||||
setIsModalVisible(true);
|
|
||||||
}
|
}
|
||||||
};
|
}, [ensureDirectory]);
|
||||||
|
|
||||||
const startUpload = useCallback(async () => {
|
const processDirectoryTask = useCallback(async (task: UploadFile) => {
|
||||||
if (files.length === 0) {
|
updateFile(task.id, { status: 'uploading', progress: 10 });
|
||||||
|
const stat = await safeStat(task.targetPath);
|
||||||
|
if (stat && !stat.is_dir) {
|
||||||
|
const error = t('Directory conflicts with existing file');
|
||||||
|
updateFile(task.id, { status: 'error', progress: 0, error });
|
||||||
|
message.error(`${task.relativePath}: ${error}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await ensureDirectory(task.targetPath);
|
||||||
|
updateFile(task.id, { status: 'success', progress: 100 });
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const error = err instanceof Error ? err.message : t('Create directory failed');
|
||||||
|
updateFile(task.id, { status: 'error', progress: 0, error });
|
||||||
|
message.error(`${task.relativePath}: ${error}`);
|
||||||
|
}
|
||||||
|
}, [ensureDirectory, updateFile, t]);
|
||||||
|
|
||||||
|
const processFileTask = useCallback(async (task: UploadFile) => {
|
||||||
|
if (!task.file) {
|
||||||
|
updateFile(task.id, { status: 'error', error: t('Missing file content') });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const dir = path === '/' ? '' : path;
|
if (skipAllRef.current) {
|
||||||
|
updateFile(task.id, { status: 'skipped', progress: 0 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
for (const uploadFile of files) {
|
let shouldOverwrite = overwriteAllRef.current;
|
||||||
if (uploadFile.status !== 'pending') continue;
|
if (!shouldOverwrite) {
|
||||||
|
const stat = await safeStat(task.targetPath);
|
||||||
setFiles(prev => prev.map(f => f.id === uploadFile.id ? { ...f, status: 'uploading' } : f));
|
if (stat) {
|
||||||
|
const decision = await awaitConflictDecision(task);
|
||||||
const dest = (dir + '/' + uploadFile.file.name).replace(/\/+/g, '/');
|
if (decision === 'skip') {
|
||||||
|
updateFile(task.id, { status: 'skipped', progress: 0 });
|
||||||
try {
|
return;
|
||||||
await vfsApi.uploadStream(dest, uploadFile.file, true, (loaded, total) => {
|
}
|
||||||
const progress = total > 0 ? (loaded / total) * 100 : 0;
|
shouldOverwrite = true;
|
||||||
setFiles(prev => prev.map(f => f.id === uploadFile.id ? { ...f, progress } : f));
|
|
||||||
});
|
|
||||||
|
|
||||||
const link = await vfsApi.getTempLinkToken(dest, 60 * 60 * 24 * 365 * 10);
|
|
||||||
const permanentLink = vfsApi.getTempPublicUrl(link.token);
|
|
||||||
|
|
||||||
setFiles(prev => prev.map(f => f.id === uploadFile.id ? { ...f, status: 'success', progress: 100, permanentLink } : f));
|
|
||||||
} catch (e: any) {
|
|
||||||
setFiles(prev => prev.map(f => f.id === uploadFile.id ? { ...f, status: 'error', error: e.message } : f));
|
|
||||||
message.error(`Upload failed: ${uploadFile.file.name} - ${e.message}`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onUploadComplete();
|
setConflict(null);
|
||||||
}, [files, path, onUploadComplete]);
|
updateFile(task.id, { status: 'uploading', progress: 0, loadedBytes: 0 });
|
||||||
|
|
||||||
|
const parentDir = task.targetPath.replace(/\/[^/]+$/, '') || '/';
|
||||||
|
try {
|
||||||
|
await ensureDirectoryTree(parentDir);
|
||||||
|
await vfsApi.uploadStream(task.targetPath, task.file, shouldOverwrite, (loaded, total) => {
|
||||||
|
mutateFiles((prev) => prev.map((f) => {
|
||||||
|
if (f.id !== task.id) return f;
|
||||||
|
const effectiveTotal = total > 0 ? total : f.size;
|
||||||
|
const size = Math.max(f.size, effectiveTotal, loaded);
|
||||||
|
const percent = size > 0 ? Math.min(100, Math.round((loaded / size) * 100)) : 0;
|
||||||
|
return {
|
||||||
|
...f,
|
||||||
|
size,
|
||||||
|
loadedBytes: loaded,
|
||||||
|
progress: percent,
|
||||||
|
};
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
const link = await vfsApi.getTempLinkToken(task.targetPath, 60 * 60 * 24 * 365 * 10);
|
||||||
|
const permanentLink = vfsApi.getTempPublicUrl(link.token);
|
||||||
|
updateFile(task.id, { status: 'success', progress: 100, loadedBytes: task.size, permanentLink });
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const error = err instanceof Error ? err.message : t('Upload failed');
|
||||||
|
updateFile(task.id, { status: 'error', error, progress: 0 });
|
||||||
|
message.error(`${task.relativePath}: ${error}`);
|
||||||
|
}
|
||||||
|
}, [ensureDirectoryTree, awaitConflictDecision, mutateFiles, updateFile, t]);
|
||||||
|
|
||||||
|
const startUpload = useCallback(async () => {
|
||||||
|
if (isUploadingRef.current) return;
|
||||||
|
if (!filesRef.current.length) return;
|
||||||
|
|
||||||
|
isUploadingRef.current = true;
|
||||||
|
setIsUploading(true);
|
||||||
|
try {
|
||||||
|
for (const task of filesRef.current) {
|
||||||
|
if (task.status !== 'pending' && task.status !== 'waiting') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (task.type === 'directory') {
|
||||||
|
await processDirectoryTask(task);
|
||||||
|
} else {
|
||||||
|
await processFileTask(task);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onUploadComplete();
|
||||||
|
} finally {
|
||||||
|
isUploadingRef.current = false;
|
||||||
|
setIsUploading(false);
|
||||||
|
}
|
||||||
|
}, [onUploadComplete, processDirectoryTask, processFileTask]);
|
||||||
|
|
||||||
|
const totalFileBytes = useMemo(
|
||||||
|
() => files.reduce((acc, f) => acc + (f.type === 'file' ? f.size : 0), 0),
|
||||||
|
[files],
|
||||||
|
);
|
||||||
|
|
||||||
|
const uploadedFileBytes = useMemo(
|
||||||
|
() => files.reduce((acc, f) => {
|
||||||
|
if (f.type !== 'file') return acc;
|
||||||
|
const loaded = Math.min(f.loadedBytes, f.size);
|
||||||
|
if (f.status === 'success') {
|
||||||
|
return acc + (f.size || loaded);
|
||||||
|
}
|
||||||
|
if (f.status === 'uploading' || f.status === 'waiting') {
|
||||||
|
return acc + loaded;
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, 0),
|
||||||
|
[files],
|
||||||
|
);
|
||||||
|
|
||||||
|
const directoryCounts = useMemo(() => {
|
||||||
|
const directories = files.filter((f) => f.type === 'directory');
|
||||||
|
const completed = directories.filter((f) => f.status === 'success').length;
|
||||||
|
return {
|
||||||
|
total: directories.length,
|
||||||
|
completed,
|
||||||
|
};
|
||||||
|
}, [files]);
|
||||||
|
|
||||||
|
const totalWeight = totalFileBytes + directoryCounts.total;
|
||||||
|
const totalProgress = totalWeight === 0
|
||||||
|
? 0
|
||||||
|
: ((uploadedFileBytes + directoryCounts.completed) / totalWeight) * 100;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
files,
|
files,
|
||||||
isModalVisible,
|
isModalVisible,
|
||||||
|
isUploading,
|
||||||
|
totalProgress: Math.min(100, Math.max(0, totalProgress)),
|
||||||
|
totalFileBytes,
|
||||||
|
uploadedFileBytes,
|
||||||
|
conflict,
|
||||||
|
confirmConflict,
|
||||||
|
resetOverwriteDecisions,
|
||||||
fileInputRef,
|
fileInputRef,
|
||||||
openModal,
|
directoryInputRef,
|
||||||
|
openFilePicker,
|
||||||
|
openDirectoryPicker,
|
||||||
closeModal,
|
closeModal,
|
||||||
handleFileChange,
|
handleFileInputChange,
|
||||||
|
handleDirectoryInputChange,
|
||||||
handleFileDrop,
|
handleFileDrop,
|
||||||
startUpload,
|
startUpload,
|
||||||
};
|
};
|
||||||
|
|||||||
104
web/src/pages/ForgotPasswordPage.tsx
Normal file
104
web/src/pages/ForgotPasswordPage.tsx
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Card, Form, Input, Button, Typography, message } from 'antd';
|
||||||
|
import { MailOutlined, ArrowLeftOutlined } from '@ant-design/icons';
|
||||||
|
import { useNavigate } from 'react-router';
|
||||||
|
import { authApi } from '../api/auth';
|
||||||
|
import { useI18n } from '../i18n';
|
||||||
|
import LanguageSwitcher from '../components/LanguageSwitcher';
|
||||||
|
|
||||||
|
const { Title, Text } = Typography;
|
||||||
|
|
||||||
|
export default function ForgotPasswordPage() {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [sent, setSent] = useState(false);
|
||||||
|
|
||||||
|
const handleSubmit = async (values: { email: string }) => {
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
await authApi.requestPasswordReset({ email: values.email });
|
||||||
|
message.success(t('If the email exists, a reset link has been sent.'));
|
||||||
|
setSent(true);
|
||||||
|
} catch (err: any) {
|
||||||
|
message.error(err?.message || t('Request failed'));
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
minHeight: '100vh',
|
||||||
|
background: 'linear-gradient(to right, var(--ant-color-bg-layout, #f0f2f5), var(--ant-color-fill-secondary, #d7d7d7))',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: '48px 16px',
|
||||||
|
position: 'relative'
|
||||||
|
}}>
|
||||||
|
<div style={{ position: 'absolute', top: 16, right: 16 }}>
|
||||||
|
<LanguageSwitcher />
|
||||||
|
</div>
|
||||||
|
<Card
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: 460,
|
||||||
|
borderRadius: 20,
|
||||||
|
boxShadow: '0 24px 60px rgba(15,23,42,0.12)',
|
||||||
|
border: '1px solid rgba(99,102,241,0.12)',
|
||||||
|
}}
|
||||||
|
styles={{ body: { padding: '40px 36px' } }}
|
||||||
|
>
|
||||||
|
<div style={{ textAlign: 'center', marginBottom: 32 }}>
|
||||||
|
<div style={{
|
||||||
|
width: 64,
|
||||||
|
height: 64,
|
||||||
|
borderRadius: '50%',
|
||||||
|
margin: '0 auto 16px',
|
||||||
|
background: 'linear-gradient(135deg,#6366f1,#8b5cf6)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: 28,
|
||||||
|
}}>
|
||||||
|
<MailOutlined />
|
||||||
|
</div>
|
||||||
|
<Title level={3} style={{ marginBottom: 8 }}>{t('Reset Your Password')}</Title>
|
||||||
|
<Text type="secondary">
|
||||||
|
{t('Enter the email linked to your account and we will send a reset link.')}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Form layout="vertical" size="large" onFinish={handleSubmit}>
|
||||||
|
<Form.Item
|
||||||
|
name="email"
|
||||||
|
label={t('Email')}
|
||||||
|
rules={[
|
||||||
|
{ required: true, message: t('Please input recipient email') },
|
||||||
|
{ type: 'email', message: t('Please input a valid email!') },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Input placeholder="me@example.com" autoComplete="email" />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item style={{ marginTop: 32 }}>
|
||||||
|
<Button type="primary" htmlType="submit" loading={submitting} block>
|
||||||
|
{sent ? t('Resend Link') : t('Send Reset Link')}
|
||||||
|
</Button>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
icon={<ArrowLeftOutlined />}
|
||||||
|
onClick={() => navigate('/login')}
|
||||||
|
style={{ padding: 0 }}
|
||||||
|
>
|
||||||
|
{t('Back to login')}
|
||||||
|
</Button>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -107,6 +107,12 @@ export default function LoginPage() {
|
|||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
|
<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>
|
<Form.Item>
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { memo, useState, useEffect, useCallback } from 'react';
|
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 PageCard from '../components/PageCard';
|
||||||
import { logsApi, type LogItem, type PaginatedLogs } from '../api/logs';
|
import { logsApi, type LogItem, type PaginatedLogs } from '../api/logs';
|
||||||
import { useI18n } from '../i18n';
|
import { useI18n } from '../i18n';
|
||||||
@@ -8,6 +8,7 @@ import { format, formatISO } from 'date-fns';
|
|||||||
const { RangePicker } = DatePicker;
|
const { RangePicker } = DatePicker;
|
||||||
|
|
||||||
const LOG_LEVELS = ['API', 'INFO', 'WARNING', 'ERROR'];
|
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 LogsPage = memo(function LogsPage() {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -74,7 +75,7 @@ const LogsPage = memo(function LogsPage() {
|
|||||||
dataIndex: 'level',
|
dataIndex: 'level',
|
||||||
width: 100,
|
width: 100,
|
||||||
render: (level: string) => {
|
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>;
|
return <Tag color={color}>{level}</Tag>;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -93,9 +94,10 @@ const LogsPage = memo(function LogsPage() {
|
|||||||
<PageCard
|
<PageCard
|
||||||
title={t('System Logs')}
|
title={t('System Logs')}
|
||||||
extra={
|
extra={
|
||||||
<Space>
|
<Space align="center">
|
||||||
<RangePicker
|
<RangePicker
|
||||||
showTime
|
showTime
|
||||||
|
size="small"
|
||||||
onChange={dates => {
|
onChange={dates => {
|
||||||
setFilters(f => ({
|
setFilters(f => ({
|
||||||
...f,
|
...f,
|
||||||
@@ -109,6 +111,7 @@ const LogsPage = memo(function LogsPage() {
|
|||||||
style={{ width: 120 }}
|
style={{ width: 120 }}
|
||||||
placeholder={t('Level')}
|
placeholder={t('Level')}
|
||||||
allowClear
|
allowClear
|
||||||
|
size="small"
|
||||||
value={filters.level || undefined}
|
value={filters.level || undefined}
|
||||||
onChange={level => setFilters(f => ({ ...f, level: level || '', page: 1 }))}
|
onChange={level => setFilters(f => ({ ...f, level: level || '', page: 1 }))}
|
||||||
options={LOG_LEVELS.map(l => ({ value: l, label: l }))}
|
options={LOG_LEVELS.map(l => ({ value: l, label: l }))}
|
||||||
@@ -116,6 +119,7 @@ const LogsPage = memo(function LogsPage() {
|
|||||||
<Input.Search
|
<Input.Search
|
||||||
style={{ width: 240 }}
|
style={{ width: 240 }}
|
||||||
placeholder={t('Search source')}
|
placeholder={t('Search source')}
|
||||||
|
size="small"
|
||||||
onSearch={source => setFilters(f => ({ ...f, source, page: 1 }))}
|
onSearch={source => setFilters(f => ({ ...f, source, page: 1 }))}
|
||||||
allowClear
|
allowClear
|
||||||
/>
|
/>
|
||||||
@@ -145,9 +149,32 @@ const LogsPage = memo(function LogsPage() {
|
|||||||
width={800}
|
width={800}
|
||||||
>
|
>
|
||||||
{selectedLog && (
|
{selectedLog && (
|
||||||
<pre style={{ maxHeight: '60vh', overflow: 'auto', background: 'var(--ant-color-fill-tertiary, #f5f5f5)', padding: 12 }}>
|
<Space direction="vertical" size={16} style={{ width: '100%' }}>
|
||||||
{JSON.stringify(selectedLog.details, null, 2)}
|
<Descriptions column={1} bordered size="small">
|
||||||
</pre>
|
<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>
|
</Modal>
|
||||||
</PageCard>
|
</PageCard>
|
||||||
|
|||||||
146
web/src/pages/ResetPasswordPage.tsx
Normal file
146
web/src/pages/ResetPasswordPage.tsx
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { Card, Form, Input, Button, Typography, message, Result } from 'antd';
|
||||||
|
import { LockOutlined, CheckCircleTwoTone } from '@ant-design/icons';
|
||||||
|
import { useLocation, useNavigate } from 'react-router';
|
||||||
|
import { authApi } from '../api/auth';
|
||||||
|
import { useI18n } from '../i18n';
|
||||||
|
import LanguageSwitcher from '../components/LanguageSwitcher';
|
||||||
|
|
||||||
|
const { Title, Text } = Typography;
|
||||||
|
|
||||||
|
export default function ResetPasswordPage() {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
const token = useMemo(() => new URLSearchParams(location.search).get('token') || '', [location.search]);
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [userInfo, setUserInfo] = useState<{ username: string; email: string } | null>(null);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [success, setSuccess] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token) {
|
||||||
|
setError(t('Reset link is invalid'));
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
authApi.verifyPasswordResetToken(token)
|
||||||
|
.then(setUserInfo)
|
||||||
|
.catch((err) => {
|
||||||
|
setError(err?.message || t('Reset link is invalid or expired'));
|
||||||
|
})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [token, t]);
|
||||||
|
|
||||||
|
const handleSubmit = async (values: { password: string; confirm: string }) => {
|
||||||
|
if (values.password !== values.confirm) {
|
||||||
|
message.error(t('Passwords do not match'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
await authApi.confirmPasswordReset({ token, password: values.password });
|
||||||
|
setSuccess(true);
|
||||||
|
message.success(t('Password updated, please login again.'));
|
||||||
|
setTimeout(() => navigate('/login'), 1500);
|
||||||
|
} catch (err: any) {
|
||||||
|
message.error(err?.message || t('Failed to reset password'));
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<Result
|
||||||
|
status="error"
|
||||||
|
title={t('Reset failed')}
|
||||||
|
subTitle={error}
|
||||||
|
extra={[
|
||||||
|
<Button type="primary" key="back" onClick={() => navigate('/forgot-password')}>
|
||||||
|
{t('Try again')}
|
||||||
|
</Button>,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
minHeight: '100vh',
|
||||||
|
background: 'linear-gradient(to right, var(--ant-color-bg-layout, #f0f2f5), var(--ant-color-fill-secondary, #d7d7d7))',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: '48px 16px',
|
||||||
|
position: 'relative'
|
||||||
|
}}>
|
||||||
|
<div style={{ position: 'absolute', top: 16, right: 16 }}>
|
||||||
|
<LanguageSwitcher />
|
||||||
|
</div>
|
||||||
|
<Card
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: 480,
|
||||||
|
borderRadius: 20,
|
||||||
|
border: '1px solid rgba(99,102,241,0.14)',
|
||||||
|
boxShadow: '0 24px 60px rgba(79,70,229,0.18)',
|
||||||
|
}}
|
||||||
|
bodyStyle={{ padding: '40px 36px' }}
|
||||||
|
>
|
||||||
|
<div style={{ textAlign: 'center', marginBottom: 32 }}>
|
||||||
|
<div style={{
|
||||||
|
width: 64,
|
||||||
|
height: 64,
|
||||||
|
borderRadius: '50%',
|
||||||
|
margin: '0 auto 16px',
|
||||||
|
background: success ? '#ecfdf5' : 'linear-gradient(135deg,#6366f1,#8b5cf6)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
color: success ? '#047857' : '#fff',
|
||||||
|
fontSize: success ? 32 : 28,
|
||||||
|
}}>
|
||||||
|
{success ? <CheckCircleTwoTone twoToneColor="#22c55e" /> : <LockOutlined />}
|
||||||
|
</div>
|
||||||
|
<Title level={3} style={{ marginBottom: 8 }}>{t('Set a new password')}</Title>
|
||||||
|
{userInfo && <Text type="secondary">{userInfo.email}</Text>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Form layout="vertical" size="large" onFinish={handleSubmit}>
|
||||||
|
<Form.Item
|
||||||
|
name="password"
|
||||||
|
label={t('New Password')}
|
||||||
|
rules={[{ required: true, message: t('Please enter new password') }]}
|
||||||
|
>
|
||||||
|
<Input.Password autoComplete="new-password" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="confirm"
|
||||||
|
label={t('Confirm Password')}
|
||||||
|
rules={[{ required: true, message: t('Please confirm new password') }]}
|
||||||
|
>
|
||||||
|
<Input.Password autoComplete="new-password" />
|
||||||
|
</Form.Item>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
htmlType="submit"
|
||||||
|
loading={submitting}
|
||||||
|
block
|
||||||
|
size="large"
|
||||||
|
>
|
||||||
|
{t('Update Password')}
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import { message, Tabs, Space } from 'antd';
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import PageCard from '../../components/PageCard';
|
import PageCard from '../../components/PageCard';
|
||||||
import { getAllConfig, setConfig } from '../../api/config';
|
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 { useTheme } from '../../contexts/ThemeContext';
|
||||||
import '../../styles/settings-tabs.css';
|
import '../../styles/settings-tabs.css';
|
||||||
import { useI18n } from '../../i18n';
|
import { useI18n } from '../../i18n';
|
||||||
@@ -10,10 +10,12 @@ import AppearanceSettingsTab from './components/AppearanceSettingsTab';
|
|||||||
import AppSettingsTab from './components/AppSettingsTab';
|
import AppSettingsTab from './components/AppSettingsTab';
|
||||||
import AiSettingsTab from './components/AiSettingsTab';
|
import AiSettingsTab from './components/AiSettingsTab';
|
||||||
import VectorDbSettingsTab from './components/VectorDbSettingsTab';
|
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 DEFAULT_TAB: TabKey = 'appearance';
|
||||||
|
|
||||||
const isValidTab = (key?: string): key is TabKey => !!key && (TAB_KEYS as string[]).includes(key);
|
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 }[] = [
|
const APP_CONFIG_KEYS: { key: string, label: string, default?: string }[] = [
|
||||||
{ key: 'APP_NAME', label: 'App Name' },
|
{ key: 'APP_NAME', label: 'App Name' },
|
||||||
{ key: 'APP_LOGO', label: 'Logo URL' },
|
{ key: 'APP_LOGO', label: 'Logo URL' },
|
||||||
|
{ key: 'APP_FAVICON', label: 'Favicon URL', default: '/logo.svg' },
|
||||||
{ key: 'APP_DOMAIN', label: 'App Domain' },
|
{ key: 'APP_DOMAIN', label: 'App Domain' },
|
||||||
{ key: 'FILE_DOMAIN', label: 'File Domain' },
|
{ key: 'FILE_DOMAIN', label: 'File Domain' },
|
||||||
];
|
];
|
||||||
@@ -107,13 +110,12 @@ export default function SystemSettingsPage({ tabKey, onTabNavigate }: SystemSett
|
|||||||
<PageCard
|
<PageCard
|
||||||
title={t('System Settings')}
|
title={t('System Settings')}
|
||||||
>
|
>
|
||||||
<Space direction="vertical" style={{ width: '100%' }} size={32}>
|
<Space direction="vertical" style={{ width: '100%' }} size={16}>
|
||||||
<Tabs
|
<Tabs
|
||||||
className="fx-settings-tabs"
|
className="fx-settings-tabs"
|
||||||
activeKey={activeTab}
|
activeKey={activeTab}
|
||||||
onChange={handleTabChange}
|
onChange={handleTabChange}
|
||||||
centered
|
centered
|
||||||
tabPosition="left"
|
|
||||||
items={[
|
items={[
|
||||||
{
|
{
|
||||||
key: 'appearance',
|
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',
|
key: 'ai',
|
||||||
label: (
|
label: (
|
||||||
@@ -174,6 +192,22 @@ export default function SystemSettingsPage({ tabKey, onTabNavigate }: SystemSett
|
|||||||
<VectorDbSettingsTab isActive={activeTab === 'vector-db'} />
|
<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>
|
</Space>
|
||||||
|
|||||||
440
web/src/pages/SystemSettingsPage/components/EmailSettingsTab.tsx
Normal file
440
web/src/pages/SystemSettingsPage/components/EmailSettingsTab.tsx
Normal file
@@ -0,0 +1,440 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Col,
|
||||||
|
Descriptions,
|
||||||
|
Divider,
|
||||||
|
Form,
|
||||||
|
Input,
|
||||||
|
InputNumber,
|
||||||
|
Row,
|
||||||
|
Select,
|
||||||
|
Space,
|
||||||
|
Typography,
|
||||||
|
message,
|
||||||
|
Tag,
|
||||||
|
Skeleton,
|
||||||
|
} from 'antd';
|
||||||
|
import { HighlightOutlined, EyeOutlined, SaveOutlined, SendOutlined, InfoCircleOutlined } from '@ant-design/icons';
|
||||||
|
import { useI18n } from '../../../i18n';
|
||||||
|
import {
|
||||||
|
sendTestEmail,
|
||||||
|
getEmailTemplate,
|
||||||
|
updateEmailTemplate,
|
||||||
|
previewEmailTemplate,
|
||||||
|
} from '../../../api/email';
|
||||||
|
|
||||||
|
interface EmailSettingsTabProps {
|
||||||
|
config: Record<string, string>;
|
||||||
|
loading: boolean;
|
||||||
|
onSave: (values: Record<string, unknown>) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EmailFormValues {
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
username?: string;
|
||||||
|
password?: string;
|
||||||
|
sender_name?: string;
|
||||||
|
sender_email: string;
|
||||||
|
security: 'none' | 'ssl' | 'starttls';
|
||||||
|
timeout?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TestFormValues {
|
||||||
|
to: string;
|
||||||
|
subject: string;
|
||||||
|
username?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PreviewContext extends Record<string, unknown> {
|
||||||
|
username: string;
|
||||||
|
reset_link: string;
|
||||||
|
expire_minutes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_FORM: EmailFormValues = {
|
||||||
|
host: '',
|
||||||
|
port: 465,
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
sender_name: '',
|
||||||
|
sender_email: '',
|
||||||
|
security: 'ssl',
|
||||||
|
timeout: 30,
|
||||||
|
};
|
||||||
|
|
||||||
|
const TEMPLATE_NAME = 'password_reset';
|
||||||
|
|
||||||
|
function parseEmailConfig(raw?: string | null): EmailFormValues {
|
||||||
|
if (!raw) return { ...DEFAULT_FORM };
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(raw) as Partial<EmailFormValues>;
|
||||||
|
return {
|
||||||
|
...DEFAULT_FORM,
|
||||||
|
...data,
|
||||||
|
port: Number(data?.port ?? DEFAULT_FORM.port),
|
||||||
|
timeout: data?.timeout !== undefined ? Number(data.timeout) : DEFAULT_FORM.timeout,
|
||||||
|
security: (data?.security ?? DEFAULT_FORM.security) as EmailFormValues['security'],
|
||||||
|
};
|
||||||
|
} catch (_err) {
|
||||||
|
return { ...DEFAULT_FORM };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EmailSettingsTab({ config, loading, onSave }: EmailSettingsTabProps) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const [testForm] = Form.useForm<TestFormValues>();
|
||||||
|
const [previewForm] = Form.useForm<PreviewContext>();
|
||||||
|
const [testing, setTesting] = useState(false);
|
||||||
|
const [template, setTemplate] = useState<string>('');
|
||||||
|
const [templateLoading, setTemplateLoading] = useState(true);
|
||||||
|
const [templateSaving, setTemplateSaving] = useState(false);
|
||||||
|
const [previewing, setPreviewing] = useState(false);
|
||||||
|
const [previewHtml, setPreviewHtml] = useState<string>('');
|
||||||
|
|
||||||
|
const initialValues = useMemo(() => parseEmailConfig(config?.EMAIL_CONFIG), [config]);
|
||||||
|
|
||||||
|
const summary = useMemo(() => {
|
||||||
|
const parsed = parseEmailConfig(config?.EMAIL_CONFIG);
|
||||||
|
return [
|
||||||
|
{ label: t('SMTP Host'), value: parsed.host || '-' },
|
||||||
|
{ label: t('SMTP Port'), value: parsed.port || '-' },
|
||||||
|
{ label: t('Security'), value: parsed.security.toUpperCase() },
|
||||||
|
{ label: t('Sender Email'), value: parsed.sender_email || '-' },
|
||||||
|
{ label: t('Sender Name'), value: parsed.sender_name || t('Not set') },
|
||||||
|
{ label: t('Timeout (seconds)'), value: parsed.timeout || '-' },
|
||||||
|
];
|
||||||
|
}, [config, t]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setTemplateLoading(true);
|
||||||
|
getEmailTemplate(TEMPLATE_NAME)
|
||||||
|
.then((res) => setTemplate(res.content))
|
||||||
|
.catch((err) => {
|
||||||
|
message.error(err?.message || t('Failed to load template'));
|
||||||
|
})
|
||||||
|
.finally(() => setTemplateLoading(false));
|
||||||
|
}, [t]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
previewForm.setFieldsValue({
|
||||||
|
username: 'Foxel 用户',
|
||||||
|
reset_link: 'https://foxel.cc/reset-password?token=demo',
|
||||||
|
expire_minutes: 10,
|
||||||
|
});
|
||||||
|
}, [previewForm]);
|
||||||
|
|
||||||
|
const handleSaveConfig = async (values: EmailFormValues) => {
|
||||||
|
if (!values.host || !values.port || !values.sender_email) {
|
||||||
|
message.error(t('Please complete all required fields'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const payload: Record<string, unknown> = {
|
||||||
|
host: values.host.trim(),
|
||||||
|
port: Number(values.port),
|
||||||
|
sender_email: values.sender_email.trim(),
|
||||||
|
security: values.security,
|
||||||
|
};
|
||||||
|
if (!Number.isFinite(payload.port as number) || (payload.port as number) <= 0) {
|
||||||
|
message.error(t('SMTP port must be a positive number'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (values.username?.trim()) {
|
||||||
|
payload.username = values.username.trim();
|
||||||
|
}
|
||||||
|
if (values.password?.length) {
|
||||||
|
payload.password = values.password;
|
||||||
|
}
|
||||||
|
if (values.sender_name?.trim()) {
|
||||||
|
payload.sender_name = values.sender_name.trim();
|
||||||
|
}
|
||||||
|
if (values.timeout !== undefined && values.timeout !== null) {
|
||||||
|
const timeoutNumber = Number(values.timeout);
|
||||||
|
if (Number.isFinite(timeoutNumber) && timeoutNumber > 0) {
|
||||||
|
payload.timeout = timeoutNumber;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await onSave({ EMAIL_CONFIG: JSON.stringify(payload) });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTest = async () => {
|
||||||
|
try {
|
||||||
|
const values = await testForm.validateFields();
|
||||||
|
setTesting(true);
|
||||||
|
const response = await sendTestEmail({
|
||||||
|
to: values.to,
|
||||||
|
subject: values.subject,
|
||||||
|
template: 'test',
|
||||||
|
context: { username: values.username || values.to },
|
||||||
|
});
|
||||||
|
message.success(t('Test email queued (task {{taskId}})', { taskId: response.task_id }));
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err?.errorFields) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
message.error(err?.message || t('Test email failed'));
|
||||||
|
} finally {
|
||||||
|
setTesting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePreviewTemplate = async () => {
|
||||||
|
try {
|
||||||
|
const values = await previewForm.validateFields();
|
||||||
|
setPreviewing(true);
|
||||||
|
const res = await previewEmailTemplate(TEMPLATE_NAME, values);
|
||||||
|
setPreviewHtml(res.html);
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err?.errorFields) return;
|
||||||
|
message.error(err?.message || t('Preview failed'));
|
||||||
|
} finally {
|
||||||
|
setPreviewing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveTemplate = async () => {
|
||||||
|
setTemplateSaving(true);
|
||||||
|
try {
|
||||||
|
await updateEmailTemplate(TEMPLATE_NAME, template);
|
||||||
|
message.success(t('Template saved'));
|
||||||
|
} catch (err: any) {
|
||||||
|
message.error(err?.message || t('Failed to save template'));
|
||||||
|
} finally {
|
||||||
|
setTemplateSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Space direction="vertical" size={32} style={{ width: '100%', marginTop: 24 }}>
|
||||||
|
<Row gutter={24}>
|
||||||
|
<Col xs={24} lg={15}>
|
||||||
|
<Card
|
||||||
|
title={t('SMTP Settings')}
|
||||||
|
extra={<InfoCircleOutlined style={{ color: 'var(--ant-color-primary)' }} />}
|
||||||
|
bodyStyle={{ paddingBottom: 12 }}
|
||||||
|
>
|
||||||
|
<Form<EmailFormValues>
|
||||||
|
layout="vertical"
|
||||||
|
initialValues={initialValues}
|
||||||
|
onFinish={handleSaveConfig}
|
||||||
|
key={'email-settings-' + (config?.EMAIL_CONFIG ?? '')}
|
||||||
|
>
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col span={14}>
|
||||||
|
<Form.Item
|
||||||
|
name="host"
|
||||||
|
label={t('SMTP Host')}
|
||||||
|
rules={[{ required: true, message: t('Please input SMTP host') }]}
|
||||||
|
>
|
||||||
|
<Input size="large" />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col span={10}>
|
||||||
|
<Form.Item
|
||||||
|
name="port"
|
||||||
|
label={t('SMTP Port')}
|
||||||
|
rules={[{ required: true, message: t('Please input SMTP port') }]}
|
||||||
|
>
|
||||||
|
<InputNumber min={1} style={{ width: '100%' }} size="large" />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col span={12}>
|
||||||
|
<Form.Item name="security" label={t('Security')}>
|
||||||
|
<Select
|
||||||
|
size="large"
|
||||||
|
options={[
|
||||||
|
{ value: 'none', label: t('None') },
|
||||||
|
{ value: 'ssl', label: 'SSL' },
|
||||||
|
{ value: 'starttls', label: 'STARTTLS' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<Form.Item name="timeout" label={t('Timeout (seconds)')}>
|
||||||
|
<InputNumber min={1} style={{ width: '100%' }} size="large" />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<Divider />
|
||||||
|
<Form.Item name="sender_name" label={t('Sender Name')}>
|
||||||
|
<Input size="large" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="sender_email"
|
||||||
|
label={t('Sender Email')}
|
||||||
|
rules={[
|
||||||
|
{ required: true, message: t('Please input sender email') },
|
||||||
|
{ type: 'email', message: t('Please input a valid email!') },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Input size="large" />
|
||||||
|
</Form.Item>
|
||||||
|
<Divider />
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col span={12}>
|
||||||
|
<Form.Item name="username" label={t('SMTP Username')}>
|
||||||
|
<Input size="large" autoComplete="username" />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<Form.Item name="password" label={t('SMTP Password')}>
|
||||||
|
<Input.Password size="large" autoComplete="current-password" />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<Form.Item style={{ marginTop: 24 }}>
|
||||||
|
<Button type="primary" htmlType="submit" loading={loading} icon={<SaveOutlined />} block>
|
||||||
|
{t('Save')}
|
||||||
|
</Button>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} lg={9}>
|
||||||
|
<Space direction="vertical" size={24} style={{ width: '100%' }}>
|
||||||
|
<Card title={t('Current Configuration')} bodyStyle={{ paddingBottom: 12 }}>
|
||||||
|
<Descriptions column={1} size="small" colon={false}>
|
||||||
|
{summary.map(item => (
|
||||||
|
<Descriptions.Item key={item.label} label={<TextLabel text={item.label} />}>
|
||||||
|
<Typography.Text strong>{item.value}</Typography.Text>
|
||||||
|
</Descriptions.Item>
|
||||||
|
))}
|
||||||
|
</Descriptions>
|
||||||
|
</Card>
|
||||||
|
<Card title={t('Test Email')} extra={<SendOutlined style={{ color: 'var(--ant-color-primary)' }} />}>
|
||||||
|
<Form<TestFormValues>
|
||||||
|
form={testForm}
|
||||||
|
layout="vertical"
|
||||||
|
initialValues={{
|
||||||
|
subject: t('Foxel Mail Test'),
|
||||||
|
username: '',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Form.Item
|
||||||
|
name="to"
|
||||||
|
label={t('Recipient Address')}
|
||||||
|
rules={[
|
||||||
|
{ required: true, message: t('Please input recipient email') },
|
||||||
|
{ type: 'email', message: t('Please input a valid email!') },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Input size="large" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="subject" label={t('Test Subject')}>
|
||||||
|
<Input size="large" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="username" label={t('Test User Name')}>
|
||||||
|
<Input size="large" placeholder={t('Optional')} />
|
||||||
|
</Form.Item>
|
||||||
|
<Button type="primary" onClick={handleTest} loading={testing} block icon={<SendOutlined />}>
|
||||||
|
{t('Send Test Email')}
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
</Card>
|
||||||
|
</Space>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
title={
|
||||||
|
<Space>
|
||||||
|
<HighlightOutlined />
|
||||||
|
{t('Password Reset Template')}
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
extra={
|
||||||
|
<Space>
|
||||||
|
<Button icon={<EyeOutlined />} onClick={handlePreviewTemplate} loading={previewing}>
|
||||||
|
{t('Preview')}
|
||||||
|
</Button>
|
||||||
|
<Button type="primary" icon={<SaveOutlined />} onClick={handleSaveTemplate} loading={templateSaving}>
|
||||||
|
{t('Save')}
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Row gutter={24}>
|
||||||
|
<Col xs={24} lg={14}>
|
||||||
|
{templateLoading ? (
|
||||||
|
<Skeleton active paragraph={{ rows: 8 }} />
|
||||||
|
) : (
|
||||||
|
<Input.TextArea
|
||||||
|
value={template}
|
||||||
|
onChange={(e) => setTemplate(e.target.value)}
|
||||||
|
autoSize={{ minRows: 20 }}
|
||||||
|
style={{ fontFamily: 'monospace', background: '#0f172a', color: '#e2e8f0' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div style={{ marginTop: 16 }}>
|
||||||
|
<Typography.Text type="secondary">{t('Available variables')}:</Typography.Text>
|
||||||
|
<Space wrap style={{ marginTop: 8 }}>
|
||||||
|
<Tag color="blue">${'{username}'}</Tag>
|
||||||
|
<Tag color="blue">${'{reset_link}'}</Tag>
|
||||||
|
<Tag color="blue">${'{expire_minutes}'}</Tag>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} lg={10}>
|
||||||
|
<Card title={t('Preview Context')} size="small" style={{ marginBottom: 16 }}>
|
||||||
|
<Form<PreviewContext> layout="vertical" form={previewForm}>
|
||||||
|
<Form.Item
|
||||||
|
name="username"
|
||||||
|
label="username"
|
||||||
|
rules={[{ required: true, message: t('Please complete all required fields') }]}
|
||||||
|
>
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="reset_link"
|
||||||
|
label="reset_link"
|
||||||
|
rules={[{ required: true, message: t('Please complete all required fields') }]}
|
||||||
|
>
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="expire_minutes"
|
||||||
|
label="expire_minutes"
|
||||||
|
rules={[{ required: true, message: t('Please complete all required fields') }]}
|
||||||
|
>
|
||||||
|
<InputNumber min={1} style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Card>
|
||||||
|
<Card title={t('Live Preview')} size="small" className="email-template-preview">
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
border: '1px solid rgba(148,163,184,0.2)',
|
||||||
|
borderRadius: 12,
|
||||||
|
overflow: 'hidden',
|
||||||
|
height: 360,
|
||||||
|
background: '#f8fafc',
|
||||||
|
padding: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<iframe
|
||||||
|
title="email-preview"
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
border: 'none',
|
||||||
|
backgroundColor: '#f8fafc',
|
||||||
|
}}
|
||||||
|
srcDoc={previewHtml || template}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Card>
|
||||||
|
</Space>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TextLabel({ text }: { text: string }) {
|
||||||
|
return <Typography.Text type="secondary">{text}</Typography.Text>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,306 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { Alert, Button, Card, Descriptions, Form, Input, Space, Switch, Typography } from 'antd';
|
||||||
|
import { useI18n } from '../../../i18n';
|
||||||
|
|
||||||
|
interface ProtocolMappingsTabProps {
|
||||||
|
config: Record<string, string>;
|
||||||
|
loading: boolean;
|
||||||
|
onSave: (values: Record<string, unknown>) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const WEBDAV_KEY = 'WEBDAV_MAPPING_ENABLED';
|
||||||
|
const S3_KEYS = {
|
||||||
|
ENABLED: 'S3_MAPPING_ENABLED',
|
||||||
|
BUCKET: 'S3_MAPPING_BUCKET',
|
||||||
|
REGION: 'S3_MAPPING_REGION',
|
||||||
|
BASE_PATH: 'S3_MAPPING_BASE_PATH',
|
||||||
|
ACCESS_KEY: 'S3_MAPPING_ACCESS_KEY',
|
||||||
|
SECRET_KEY: 'S3_MAPPING_SECRET_KEY',
|
||||||
|
};
|
||||||
|
|
||||||
|
const truthy = new Set(['1', 'true', 'yes', 'on']);
|
||||||
|
|
||||||
|
export default function ProtocolMappingsTab({ config, loading, onSave }: ProtocolMappingsTabProps) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const [webdavEnabled, setWebdavEnabled] = useState(() => truthy.has((config[WEBDAV_KEY] ?? '1').toLowerCase()));
|
||||||
|
const [webdavSaving, setWebdavSaving] = useState(false);
|
||||||
|
const [s3Enabled, setS3Enabled] = useState(() => truthy.has((config[S3_KEYS.ENABLED] ?? '1').toLowerCase()));
|
||||||
|
const [s3ToggleSaving, setS3ToggleSaving] = useState(false);
|
||||||
|
const [s3FormSaving, setS3FormSaving] = useState(false);
|
||||||
|
const [s3Form] = Form.useForm();
|
||||||
|
const watchBucket = Form.useWatch('bucket', s3Form);
|
||||||
|
const watchRegion = Form.useWatch('region', s3Form);
|
||||||
|
const watchBasePath = Form.useWatch('basePath', s3Form);
|
||||||
|
const watchAccessKey = Form.useWatch('accessKey', s3Form);
|
||||||
|
const watchSecretKey = Form.useWatch('secretKey', s3Form);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setWebdavEnabled(truthy.has((config[WEBDAV_KEY] ?? '1').toLowerCase()));
|
||||||
|
setS3Enabled(truthy.has((config[S3_KEYS.ENABLED] ?? '1').toLowerCase()));
|
||||||
|
s3Form.setFieldsValue({
|
||||||
|
bucket: config[S3_KEYS.BUCKET] ?? 'foxel',
|
||||||
|
region: config[S3_KEYS.REGION] ?? 'us-east-1',
|
||||||
|
basePath: config[S3_KEYS.BASE_PATH] ?? '/',
|
||||||
|
accessKey: config[S3_KEYS.ACCESS_KEY] ?? '',
|
||||||
|
secretKey: config[S3_KEYS.SECRET_KEY] ?? '',
|
||||||
|
});
|
||||||
|
}, [config, s3Form]);
|
||||||
|
|
||||||
|
const webdavEndpoint = useMemo(() => {
|
||||||
|
const configured = (config.APP_DOMAIN ?? '').trim();
|
||||||
|
if (configured) {
|
||||||
|
const hasProtocol = configured.startsWith('http://') || configured.startsWith('https://');
|
||||||
|
const base = hasProtocol ? configured : `https://${configured}`;
|
||||||
|
return base.replace(/\/$/, '') + '/webdav';
|
||||||
|
}
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
return window.location.origin.replace(/\/$/, '') + '/webdav';
|
||||||
|
}
|
||||||
|
return '/webdav';
|
||||||
|
}, [config.APP_DOMAIN]);
|
||||||
|
|
||||||
|
const baseOrigin = useMemo(() => {
|
||||||
|
const configured = (config.APP_DOMAIN ?? '').trim();
|
||||||
|
if (configured) {
|
||||||
|
const hasProtocol = configured.startsWith('http://') || configured.startsWith('https://');
|
||||||
|
return (hasProtocol ? configured : `https://${configured}`).replace(/\/$/, '');
|
||||||
|
}
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
return window.location.origin.replace(/\/$/, '');
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}, [config.APP_DOMAIN]);
|
||||||
|
|
||||||
|
const bucketValue = (watchBucket ?? config[S3_KEYS.BUCKET] ?? 'foxel').trim() || 'foxel';
|
||||||
|
const s3Endpoint = useMemo(() => {
|
||||||
|
if (!baseOrigin) return '/s3';
|
||||||
|
return `${baseOrigin.replace(/\/$/, '')}/s3`;
|
||||||
|
}, [baseOrigin]);
|
||||||
|
const bucketApiPath = useMemo(() => `${s3Endpoint.replace(/\/$/, '')}/${encodeURIComponent(bucketValue)}`, [s3Endpoint, bucketValue]);
|
||||||
|
|
||||||
|
const handleToggleS3 = async (checked: boolean) => {
|
||||||
|
setS3ToggleSaving(true);
|
||||||
|
try {
|
||||||
|
await onSave({ [S3_KEYS.ENABLED]: checked ? '1' : '0' });
|
||||||
|
setS3Enabled(checked);
|
||||||
|
} finally {
|
||||||
|
setS3ToggleSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeBasePath = (value?: string) => {
|
||||||
|
const trimmed = (value ?? '/').trim();
|
||||||
|
if (!trimmed) return '/';
|
||||||
|
if (!trimmed.startsWith('/')) {
|
||||||
|
return `/${trimmed}`;
|
||||||
|
}
|
||||||
|
return trimmed.replace(/\/+$/, '') || '/';
|
||||||
|
};
|
||||||
|
|
||||||
|
const regionValue = (watchRegion ?? config[S3_KEYS.REGION] ?? 'us-east-1').trim() || 'us-east-1';
|
||||||
|
const basePathValue = normalizeBasePath(watchBasePath ?? config[S3_KEYS.BASE_PATH] ?? '/');
|
||||||
|
const accessKeyValue = (watchAccessKey ?? config[S3_KEYS.ACCESS_KEY] ?? '').trim();
|
||||||
|
const secretValue = (watchSecretKey ?? config[S3_KEYS.SECRET_KEY] ?? '').trim();
|
||||||
|
const exampleCommand = `aws --endpoint-url ${s3Endpoint} s3 ls s3://${bucketValue}/`;
|
||||||
|
|
||||||
|
const handleSaveS3 = async (values: Record<string, string>) => {
|
||||||
|
setS3FormSaving(true);
|
||||||
|
try {
|
||||||
|
await onSave({
|
||||||
|
[S3_KEYS.BUCKET]: values.bucket?.trim() || 'foxel',
|
||||||
|
[S3_KEYS.REGION]: values.region?.trim() || 'us-east-1',
|
||||||
|
[S3_KEYS.BASE_PATH]: normalizeBasePath(values.basePath),
|
||||||
|
[S3_KEYS.ACCESS_KEY]: values.accessKey?.trim() || '',
|
||||||
|
[S3_KEYS.SECRET_KEY]: values.secretKey?.trim() || '',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setS3FormSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasS3Credentials = Boolean(accessKeyValue && secretValue);
|
||||||
|
|
||||||
|
const handleToggleWebdav = async (checked: boolean) => {
|
||||||
|
setWebdavSaving(true);
|
||||||
|
try {
|
||||||
|
await onSave({ [WEBDAV_KEY]: checked ? '1' : '0' });
|
||||||
|
setWebdavEnabled(checked);
|
||||||
|
} finally {
|
||||||
|
setWebdavSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Space direction="vertical" size={16} style={{ width: '100%' }}>
|
||||||
|
<Card
|
||||||
|
title={t('WebDAV Mapping')}
|
||||||
|
extra={(
|
||||||
|
<Space size={12} align="center">
|
||||||
|
<Switch
|
||||||
|
checked={webdavEnabled}
|
||||||
|
loading={webdavSaving}
|
||||||
|
disabled={loading}
|
||||||
|
onChange={handleToggleWebdav}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Descriptions
|
||||||
|
column={1}
|
||||||
|
size="small"
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
key: 'endpoint',
|
||||||
|
label: t('WebDAV Endpoint'),
|
||||||
|
children: (
|
||||||
|
<Typography.Text copyable={{ text: webdavEndpoint }}>
|
||||||
|
<code>{webdavEndpoint}</code>
|
||||||
|
</Typography.Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'auth',
|
||||||
|
label: t('Authentication'),
|
||||||
|
children: t('Basic (system account password)'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'root',
|
||||||
|
label: t('Root Path'),
|
||||||
|
children: '/webdav',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'compat',
|
||||||
|
label: t('Client Compatibility'),
|
||||||
|
children: t('Supports Finder, Windows network drive, rclone, and other WebDAV clients.'),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Typography.Text type="secondary">
|
||||||
|
{t('Toggle the switch to expose the virtual file system via WebDAV.')}
|
||||||
|
</Typography.Text>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
title={t('S3 Mapping')}
|
||||||
|
extra={(
|
||||||
|
<Switch
|
||||||
|
checked={s3Enabled}
|
||||||
|
loading={s3ToggleSaving}
|
||||||
|
disabled={loading}
|
||||||
|
onChange={handleToggleS3}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Space direction="vertical" size={16} style={{ width: '100%' }}>
|
||||||
|
{!hasS3Credentials && (
|
||||||
|
<Alert
|
||||||
|
type="warning"
|
||||||
|
message={t('Configure Access Key and Secret to enable S3 mapping.')}
|
||||||
|
showIcon
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Descriptions
|
||||||
|
column={1}
|
||||||
|
size="small"
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
key: 'endpoint',
|
||||||
|
label: t('S3 Endpoint'),
|
||||||
|
children: (
|
||||||
|
<Typography.Text copyable={{ text: s3Endpoint }}>
|
||||||
|
<code>{s3Endpoint}</code>
|
||||||
|
</Typography.Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'bucket',
|
||||||
|
label: t('Bucket Name'),
|
||||||
|
children: bucketValue,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'bucket-path',
|
||||||
|
label: t('Bucket API Path'),
|
||||||
|
children: (
|
||||||
|
<Typography.Text copyable={{ text: bucketApiPath }}>
|
||||||
|
<code>{bucketApiPath}</code>
|
||||||
|
</Typography.Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'region',
|
||||||
|
label: t('Region'),
|
||||||
|
children: regionValue,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'base-path',
|
||||||
|
label: t('Base Path'),
|
||||||
|
children: basePathValue,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'access',
|
||||||
|
label: t('Access Key'),
|
||||||
|
children: accessKeyValue ? (
|
||||||
|
<Typography.Text copyable={{ text: accessKeyValue }}>{accessKeyValue}</Typography.Text>
|
||||||
|
) : t('Not set'),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Form
|
||||||
|
form={s3Form}
|
||||||
|
layout="vertical"
|
||||||
|
onFinish={handleSaveS3}
|
||||||
|
disabled={!s3Enabled || loading}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
>
|
||||||
|
<Form.Item
|
||||||
|
name="bucket"
|
||||||
|
label={t('Bucket Name')}
|
||||||
|
rules={[{ required: true, message: t('Please input bucket name') }]}
|
||||||
|
>
|
||||||
|
<Input disabled={!s3Enabled || loading} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="region"
|
||||||
|
label={t('Region')}
|
||||||
|
rules={[{ required: true, message: t('Please input region') }]}
|
||||||
|
>
|
||||||
|
<Input disabled={!s3Enabled || loading} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="basePath"
|
||||||
|
label={t('Base Path')}
|
||||||
|
tooltip={t('Mount point inside the virtual file system (e.g. / or /workspace).')}
|
||||||
|
>
|
||||||
|
<Input disabled={!s3Enabled || loading} placeholder="/" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="accessKey"
|
||||||
|
label={t('Access Key')}
|
||||||
|
rules={[{ required: true, message: t('Please input access key') }]}
|
||||||
|
>
|
||||||
|
<Input disabled={!s3Enabled || loading} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="secretKey"
|
||||||
|
label={t('Secret Key')}
|
||||||
|
rules={[{ required: true, message: t('Please input secret key') }]}
|
||||||
|
>
|
||||||
|
<Input.Password disabled={!s3Enabled || loading} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item>
|
||||||
|
<Button type="primary" htmlType="submit" loading={s3FormSaving} disabled={!s3Enabled} block>
|
||||||
|
{t('Save S3 Settings')}
|
||||||
|
</Button>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
<Typography.Paragraph type="secondary">
|
||||||
|
{t('Example CLI command')}
|
||||||
|
<Typography.Text code style={{ display: 'block', marginTop: 8 }} copyable={{ text: exampleCommand }}>
|
||||||
|
{exampleCommand}
|
||||||
|
</Typography.Text>
|
||||||
|
</Typography.Paragraph>
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
</Space>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@ import LayoutShell from './LayoutShell.tsx';
|
|||||||
import LoginPage from '../pages/LoginPage.tsx';
|
import LoginPage from '../pages/LoginPage.tsx';
|
||||||
import SetupPage from '../pages/SetupPage.tsx';
|
import SetupPage from '../pages/SetupPage.tsx';
|
||||||
import PublicSharePage from '../pages/PublicSharePage';
|
import PublicSharePage from '../pages/PublicSharePage';
|
||||||
|
import ForgotPasswordPage from '../pages/ForgotPasswordPage';
|
||||||
|
import ResetPasswordPage from '../pages/ResetPasswordPage';
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
import type { JSX } from 'react';
|
import type { JSX } from 'react';
|
||||||
|
|
||||||
@@ -13,12 +15,16 @@ export const routes: RouteObject[] = [
|
|||||||
{ path: '/login', element: <LoginPage /> },
|
{ path: '/login', element: <LoginPage /> },
|
||||||
{ path: '/share/:token', element: <PublicSharePage /> },
|
{ path: '/share/:token', element: <PublicSharePage /> },
|
||||||
{ path: '/setup', element: <SetupPage /> },
|
{ path: '/setup', element: <SetupPage /> },
|
||||||
|
{ path: '/forgot-password', element: <ForgotPasswordPage /> },
|
||||||
|
{ path: '/reset-password', element: <ResetPasswordPage /> },
|
||||||
];
|
];
|
||||||
|
|
||||||
function RequireAuth({ children }: { children: JSX.Element }) {
|
function RequireAuth({ children }: { children: JSX.Element }) {
|
||||||
const { isAuthenticated } = useAuth();
|
const { isAuthenticated } = useAuth();
|
||||||
const location = useLocation();
|
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 <Navigate to="/login" replace />;
|
||||||
}
|
}
|
||||||
return children;
|
return children;
|
||||||
|
|||||||
@@ -1,11 +1,19 @@
|
|||||||
|
.fx-settings-tabs .ant-tabs-nav {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.fx-settings-tabs .ant-tabs-nav-list {
|
.fx-settings-tabs .ant-tabs-nav-list {
|
||||||
padding: 8px 4px;
|
padding: 4px 8px;
|
||||||
|
width: 100%;
|
||||||
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fx-settings-tabs .ant-tabs-tab {
|
.fx-settings-tabs .ant-tabs-tab {
|
||||||
margin: 4px 0 !important;
|
margin: 0 !important;
|
||||||
border-radius: 8px;
|
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 {
|
.fx-settings-tabs .ant-tabs-tab .ant-tabs-tab-btn {
|
||||||
|
|||||||
Reference in New Issue
Block a user