Compare commits

...

4 Commits

Author SHA1 Message Date
f4052d229a fix: clean up empty parent directories after file deletion
All checks were successful
Test / test (push) Successful in 2m32s
Prevent local storage fragmentation by removing empty directories
left behind when files are permanently deleted or moved to trash.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 15:56:44 +08:00
bc2182720d feat: implement avatar upload, Gravatar support, and avatar settings
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 15:56:24 +08:00
eddf38d316 chore: remove applied migration script
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 15:56:07 +08:00
03e768d232 chore: update .gitignore for avatar and dev directories
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 15:56:00 +08:00
8 changed files with 227 additions and 117 deletions

5
.gitignore vendored
View File

@@ -1,8 +1,6 @@
# Python
__pycache__/
*.py[cod]
*.pyo
*.pyd
*.so
*.egg
*.egg-info/
@@ -79,3 +77,6 @@ statics/
# 许可证密钥(保密)
license_private.pem
license.key
avatar/
.dev/

View File

@@ -1,88 +0,0 @@
-- 迁移:文件元数据系统重构
-- 将固定列 FileMetadata 表替换为灵活的 ObjectMetadata KV 表
-- 日期2026-02-23
BEGIN;
-- ==================== 1. object 表新增 mime_type 列 ====================
ALTER TABLE object ADD COLUMN IF NOT EXISTS mime_type VARCHAR(127);
-- ==================== 2. physicalfile 表新增 checksum_sha256 列 ====================
ALTER TABLE physicalfile ADD COLUMN IF NOT EXISTS checksum_sha256 VARCHAR(64);
-- ==================== 3. 创建 objectmetadata KV 表 ====================
CREATE TABLE IF NOT EXISTS objectmetadata (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
object_id UUID NOT NULL REFERENCES object(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
value TEXT NOT NULL,
is_public BOOLEAN NOT NULL DEFAULT false,
CONSTRAINT uq_object_metadata_object_name UNIQUE (object_id, name)
);
CREATE INDEX IF NOT EXISTS ix_object_metadata_object_id
ON objectmetadata (object_id);
-- ==================== 4. 创建 custompropertydefinition 表 ====================
CREATE TABLE IF NOT EXISTS custompropertydefinition (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
owner_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
name VARCHAR(100) NOT NULL,
type VARCHAR NOT NULL,
icon VARCHAR(100),
options JSON,
default_value VARCHAR(500),
sort_order INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS ix_custompropertydefinition_owner_id
ON custompropertydefinition (owner_id);
-- ==================== 5. 迁移旧数据(从 filemetadata 到 objectmetadata====================
-- 将 filemetadata 中的数据迁移到 objectmetadata KV 格式
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'filemetadata') THEN
-- 同时将 mime_type 上提到 object 表
UPDATE object o
SET mime_type = fm.mime_type
FROM filemetadata fm
WHERE fm.object_id = o.id AND fm.mime_type IS NOT NULL;
-- 将 checksum_sha256 迁移到 physicalfile 表
UPDATE physicalfile pf
SET checksum_sha256 = fm.checksum_sha256
FROM filemetadata fm
JOIN object o ON fm.object_id = o.id
WHERE o.physical_file_id = pf.id AND fm.checksum_sha256 IS NOT NULL;
-- 迁移 width → exif:width
INSERT INTO objectmetadata (id, object_id, name, value, is_public)
SELECT gen_random_uuid(), object_id, 'exif:width', CAST(width AS TEXT), true
FROM filemetadata WHERE width IS NOT NULL;
-- 迁移 height → exif:height
INSERT INTO objectmetadata (id, object_id, name, value, is_public)
SELECT gen_random_uuid(), object_id, 'exif:height', CAST(height AS TEXT), true
FROM filemetadata WHERE height IS NOT NULL;
-- 迁移 duration → stream:duration
INSERT INTO objectmetadata (id, object_id, name, value, is_public)
SELECT gen_random_uuid(), object_id, 'stream:duration', CAST(duration AS TEXT), true
FROM filemetadata WHERE duration IS NOT NULL;
-- 迁移 bitrate → stream:bitrate
INSERT INTO objectmetadata (id, object_id, name, value, is_public)
SELECT gen_random_uuid(), object_id, 'stream:bitrate', CAST(bitrate AS TEXT), true
FROM filemetadata WHERE bitrate IS NOT NULL;
-- 删除旧表
DROP TABLE filemetadata;
END IF;
END $$;
COMMIT;

View File

@@ -82,7 +82,8 @@ async def router_site_config(session: SessionDep) -> SiteConfigResponse:
(Setting.type == SettingsType.REGISTER) |
(Setting.type == SettingsType.CAPTCHA) |
(Setting.type == SettingsType.AUTH) |
(Setting.type == SettingsType.OAUTH),
(Setting.type == SettingsType.OAUTH) |
(Setting.type == SettingsType.AVATAR),
fetch_mode="all",
)
@@ -122,6 +123,7 @@ async def router_site_config(session: SessionDep) -> SiteConfigResponse:
password_required=s.get("auth_password_required") == "1",
phone_binding_required=s.get("auth_phone_binding_required") == "1",
email_binding_required=s.get("auth_email_binding_required") == "1",
avatar_max_size=int(s["avatar_size"]),
footer_code=s.get("footer_code"),
tos_url=s.get("tos_url"),
privacy_url=s.get("privacy_url"),

View File

@@ -5,6 +5,7 @@ import json
import jwt
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import FileResponse, RedirectResponse
from itsdangerous import URLSafeTimedSerializer
from loguru import logger
from webauthn import (
@@ -358,20 +359,78 @@ def router_user_profile(id: str) -> sqlmodels.ResponseBase:
@user_router.get(
path='/avatar/{id}/{size}',
summary='获取用户头像',
description='Get user avatar by ID and size.',
response_model=None,
)
def router_user_avatar(id: str, size: int = 128) -> sqlmodels.ResponseBase:
async def router_user_avatar(
session: SessionDep,
id: UUID,
size: int = 128,
) -> FileResponse | RedirectResponse:
"""
Get user avatar by ID and size.
获取指定用户指定尺寸的头像(公开端点,无需认证)
Args:
id (str): The user ID.
size (int): The size of the avatar image.
路径参数:
- id: 用户 UUID
- size: 请求的头像尺寸px默认 128
Returns:
str: A Base64 encoded string of the user avatar image.
行为:
- default: 302 重定向到 Gravatar identicon
- gravatar: 302 重定向到 Gravatar使用用户邮箱 MD5
- file: 返回本地 WebP 文件
响应:
- 200: image/webpfile 模式)
- 302: 重定向到外部 URLdefault/gravatar 模式)
- 404: 用户不存在
缓存Cache-Control: public, max-age=3600
"""
http_exceptions.raise_not_implemented()
import aiofiles.os
from service.avatar import (
get_avatar_file_path,
get_avatar_settings,
gravatar_url,
resolve_avatar_size,
)
user = await sqlmodels.User.get(session, sqlmodels.User.id == id)
if not user:
http_exceptions.raise_not_found("用户不存在")
avatar_path, _, size_l, size_m, size_s = await get_avatar_settings(session)
if user.avatar == "file":
size_label = resolve_avatar_size(size, size_l, size_m, size_s)
file_path = get_avatar_file_path(avatar_path, user.id, size_label)
if not await aiofiles.os.path.exists(file_path):
# 文件丢失,降级为 identicon
fallback_url = gravatar_url(str(user.id), size, "https://www.gravatar.com/")
return RedirectResponse(url=fallback_url, status_code=302)
return FileResponse(
path=file_path,
media_type="image/webp",
headers={"Cache-Control": "public, max-age=3600"},
)
elif user.avatar == "gravatar":
gravatar_setting = await sqlmodels.Setting.get(
session,
(sqlmodels.Setting.type == sqlmodels.SettingsType.AVATAR)
& (sqlmodels.Setting.name == "gravatar_server"),
)
server = gravatar_setting.value if gravatar_setting else "https://www.gravatar.com/"
email = user.email or str(user.id)
url = gravatar_url(email, size, server)
return RedirectResponse(url=url, status_code=302)
else:
# default: identicon
email_or_id = user.email or str(user.id)
url = gravatar_url(email_or_id, size, "https://www.gravatar.com/")
return RedirectResponse(url=url, status_code=302)
#####################
# 需要登录的接口

View File

@@ -1,7 +1,7 @@
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
from itsdangerous import URLSafeTimedSerializer, BadSignature, SignatureExpired
import sqlmodels
@@ -165,34 +165,121 @@ async def router_user_settings(
@user_settings_router.post(
path='/avatar',
summary='从文件上传头像',
description='Upload user avatar from file.',
dependencies=[Depends(auth_required)],
status_code=204,
)
def router_user_settings_avatar() -> sqlmodels.ResponseBase:
async def router_user_settings_avatar(
session: SessionDep,
user: Annotated[sqlmodels.user.User, Depends(auth_required)],
file: UploadFile = File(...),
) -> None:
"""
Upload user avatar from file.
上传头像文件
Returns:
dict: A dictionary containing the result of the avatar upload.
认证JWT token
请求体multipart/form-datafile 字段
流程:
1. 验证文件 MIME 类型JPEG/PNG/GIF/WebP
2. 验证文件大小 <= avatar_size 设置(默认 2MB
3. 调用 Pillow 验证图片有效性并处理(居中裁剪、缩放 L/M/S
4. 保存三种尺寸的 WebP 文件
5. 更新 User.avatar = "file"
错误处理:
- 400: 文件类型不支持 / 图片无法解析
- 413: 文件过大
"""
http_exceptions.raise_not_implemented()
from service.avatar import (
ALLOWED_CONTENT_TYPES,
get_avatar_settings,
process_and_save_avatar,
)
# 验证 MIME 类型
if file.content_type not in ALLOWED_CONTENT_TYPES:
http_exceptions.raise_bad_request(
f"不支持的图片格式,允许: {', '.join(ALLOWED_CONTENT_TYPES)}"
)
# 读取并验证大小
_, max_upload_size, _, _, _ = await get_avatar_settings(session)
raw_bytes = await file.read()
if len(raw_bytes) > max_upload_size:
raise HTTPException(
status_code=413,
detail=f"文件过大,最大允许 {max_upload_size} 字节",
)
# 处理并保存(内部会验证图片有效性,无效抛出 ValueError
try:
await process_and_save_avatar(session, user.id, raw_bytes)
except ValueError as e:
http_exceptions.raise_bad_request(str(e))
# 更新用户头像字段
user.avatar = "file"
await user.save(session)
@user_settings_router.put(
path='/avatar',
summary='设定为 Gravatar 头像',
description='Set user avatar to Gravatar.',
dependencies=[Depends(auth_required)],
status_code=204,
)
def router_user_settings_avatar_gravatar() -> None:
async def router_user_settings_avatar_gravatar(
session: SessionDep,
user: Annotated[sqlmodels.user.User, Depends(auth_required)],
) -> None:
"""
Set user avatar to Gravatar.
将头像切换为 Gravatar
Returns:
dict: A dictionary containing the result of setting the Gravatar avatar.
认证JWT token
流程:
1. 验证用户有邮箱Gravatar 基于邮箱 MD5
2. 如果当前是 FILE 头像,删除本地文件
3. 更新 User.avatar = "gravatar"
错误处理:
- 400: 用户没有邮箱
"""
http_exceptions.raise_not_implemented()
from service.avatar import delete_avatar_files
if not user.email:
http_exceptions.raise_bad_request("Gravatar 需要邮箱,请先绑定邮箱")
if user.avatar == "file":
await delete_avatar_files(session, user.id)
user.avatar = "gravatar"
await user.save(session)
@user_settings_router.delete(
path='/avatar',
summary='重置头像为默认',
status_code=204,
)
async def router_user_settings_avatar_delete(
session: SessionDep,
user: Annotated[sqlmodels.user.User, Depends(auth_required)],
) -> None:
"""
重置头像为默认
认证JWT token
流程:
1. 如果当前是 FILE 头像,删除本地文件
2. 更新 User.avatar = "default"
"""
from service.avatar import delete_avatar_files
if user.avatar == "file":
await delete_avatar_files(session, user.id)
user.avatar = "default"
await user.save(session)
@user_settings_router.patch(

View File

@@ -263,15 +263,49 @@ class LocalStorageService:
"""
删除文件(物理删除)
删除文件后会尝试清理因此变空的父目录。
:param path: 完整文件路径
"""
if await self.file_exists(path):
try:
await aiofiles.os.remove(path)
l.debug(f"已删除文件: {path}")
await self._cleanup_empty_parents(path)
except OSError as e:
l.warning(f"删除文件失败 {path}: {e}")
async def _cleanup_empty_parents(self, file_path: str) -> None:
"""
从被删文件的父目录开始,向上逐级删除空目录
在以下情况停止:
- 到达存储根目录_base_path
- 遇到非空目录
- 遇到 .trash 目录
- 删除失败(权限、并发等)
:param file_path: 被删文件的完整路径
"""
current = Path(file_path).parent
while current != self._base_path and str(current).startswith(str(self._base_path)):
if current.name == '.trash':
break
try:
entries = await aiofiles.os.listdir(str(current))
if entries:
break
await aiofiles.os.rmdir(str(current))
l.debug(f"已清理空目录: {current}")
current = current.parent
except OSError as e:
l.debug(f"清理空目录失败(忽略): {current}: {e}")
break
async def move_to_trash(
self,
source_path: str,
@@ -304,6 +338,7 @@ class LocalStorageService:
try:
await aiofiles.os.rename(source_path, str(trash_path))
l.info(f"文件已移动到回收站: {source_path} -> {trash_path}")
await self._cleanup_empty_parents(source_path)
return str(trash_path)
except OSError as e:
raise StorageException(f"移动文件到回收站失败: {e}")

View File

@@ -76,6 +76,9 @@ class SiteConfigResponse(SQLModelBase):
email_binding_required: bool = True
"""是否强制绑定邮箱"""
avatar_max_size: int = 2097152
"""头像文件最大字节数(默认 2MB"""
footer_code: str | None = None
"""自定义页脚代码"""

View File

@@ -23,6 +23,7 @@ sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../.
from main import app
from sqlmodels import Group, GroupClaims, GroupOptions, Object, ObjectType, Policy, PolicyType, Setting, SettingsType, User
from sqlmodels.policy import GroupPolicyLink
from sqlmodels.auth_identity import AuthIdentity, AuthProviderType
from sqlmodels.user import UserStatus
from utils import Password
@@ -108,6 +109,12 @@ async def initialized_db(test_session: AsyncSession) -> AsyncSession:
Setting(type=SettingsType.AUTH, name="auth_email_binding_required", value="1"),
Setting(type=SettingsType.OAUTH, name="github_enabled", value="0"),
Setting(type=SettingsType.OAUTH, name="qq_enabled", value="0"),
Setting(type=SettingsType.AVATAR, name="gravatar_server", value="https://www.gravatar.com/"),
Setting(type=SettingsType.AVATAR, name="avatar_size", value="2097152"),
Setting(type=SettingsType.AVATAR, name="avatar_size_l", value="200"),
Setting(type=SettingsType.AVATAR, name="avatar_size_m", value="130"),
Setting(type=SettingsType.AVATAR, name="avatar_size_s", value="50"),
Setting(type=SettingsType.PATH, name="avatar_path", value="avatar"),
]
for setting in settings:
test_session.add(setting)
@@ -156,7 +163,11 @@ async def initialized_db(test_session: AsyncSession) -> AsyncSession:
await test_session.refresh(admin_group)
await test_session.refresh(default_policy)
# 4. 创建用户组选项
# 4. 关联用户组与存储策略
test_session.add(GroupPolicyLink(group_id=default_group.id, policy_id=default_policy.id))
test_session.add(GroupPolicyLink(group_id=admin_group.id, policy_id=default_policy.id))
# 5. 创建用户组选项
default_group_options = GroupOptions(
group_id=default_group.id,
share_download=True,