Skip to content
Open
7 changes: 6 additions & 1 deletion apps/admin/dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@
ADMIN_SESSION_EXPIRE_MIN,
settings,
)
from apps.admin.services import FileService, ConfigService, LocalFileService


def _get_jwt_secret() -> bytes:
Expand Down Expand Up @@ -173,12 +172,18 @@ async def share_required_login(authorization: str = Header(default=None)):


async def get_file_service():
# 工厂调用时加载业务服务,鉴权模块不依赖上传与文件管理实现。
from apps.admin.services import FileService
return FileService()


async def get_config_service():
# 工厂调用时加载业务服务,鉴权模块不依赖上传与文件管理实现。
from apps.admin.services import ConfigService
return ConfigService()


async def get_local_file_service():
# 工厂调用时加载业务服务,鉴权模块不依赖上传与文件管理实现。
from apps.admin.services import LocalFileService
return LocalFileService()
68 changes: 34 additions & 34 deletions apps/admin/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
from apps.base.config import refresh_settings
from apps.base.services import get_stored_download, response_from_download, stored_file_of
from core.security import INTERNAL_CONFIG_KEYS, generate_jwt_secret
from apps.base.models import FileCodes, KeyValue
from apps.base.models import DeliveryCode, FileCodes, KeyValue
from apps.base.utils import get_expire_info
from apps.base.local_share import (
LOCAL_REF_MARKER,
Expand All @@ -26,6 +26,8 @@
resolve_under_local,
should_skip_storage_delete,
)
from apps.base.metadata import normalize_metadata_note, normalize_metadata_tags
from apps.base.share_storage import storage_for_share, storage_type_for_share
from fastapi import HTTPException
from core.utils import get_now, hash_password, is_password_hashed, validate_background_url

Expand Down Expand Up @@ -102,8 +104,11 @@ def _file_metadata_key(self, file_id: int) -> str:
return f"{self.FILE_METADATA_KEY_PREFIX}{file_id}"

async def _delete_file_code(self, file_code: FileCodes):
# 寄件文件与普通文件共用删除流程,失败时保留记录供重试。
# NAS 引用只删除分享记录,不能删除原始文件。
if not should_skip_storage_delete(file_code):
await self.file_storage.delete_file(stored_file_of(file_code))
storage = await storage_for_share(file_code, self._file_storage)
await storage.delete_file(stored_file_of(file_code))
await KeyValue.filter(key=self._file_metadata_key(file_code.id)).delete()
await file_code.delete()

Expand Down Expand Up @@ -220,7 +225,8 @@ async def update_file(
raise HTTPException(status_code=404, detail="文件不存在")

update_data: dict[str, Any] = {}
if code is not None and code != file_code.code:
# 历史私有文件没有公开口令,普通编辑不能改变其内部标识或公开权限。
if code is not None and not file_code.is_private and code != file_code.code:
if await FileCodes.filter(code=code).first():
raise HTTPException(status_code=400, detail="code已存在")
update_data["code"] = code
Expand Down Expand Up @@ -481,6 +487,7 @@ async def list_files(
health: str = "",
sort_by: str = "created_at",
sort_order: str = "desc",
delivery_id: int | None = None,
):
page = max(page, 1)
size = min(max(size, 1), 100)
Expand All @@ -491,7 +498,13 @@ async def list_files(
sort_by = self._normalize_sort_by(sort_by)
reverse = sort_order.strip().lower() != "asc"

all_files = await FileCodes.all()
query = FileCodes.all()
if delivery_id is not None:
# 收件列表复用文件管理的数据与操作,只限定当前管理员选中的寄件码。
if not await DeliveryCode.filter(id=delivery_id).exists():
raise HTTPException(404, "寄件码不存在")
query = query.filter(delivery_id=delivery_id)
all_files = await query
now = await get_now()
enriched_files = []
summary = {
Expand Down Expand Up @@ -582,6 +595,8 @@ async def _build_admin_file_item(
) -> dict[str, Any]:
if now is None:
now = await get_now()
# 兼容未携带寄件字段的原文件列表对象,普通文件仍按原规则展示。
is_private = getattr(file_code, "is_private", False)
is_text = file_code.text is not None
is_expired = await file_code.is_expired()
name = f"{file_code.prefix}{file_code.suffix}"
Expand All @@ -593,7 +608,8 @@ async def _build_admin_file_item(
)
data = {
"id": file_code.id,
"code": file_code.code,
"code": "" if is_private else file_code.code,
"is_private": is_private,
"prefix": file_code.prefix,
"suffix": file_code.suffix,
"uuid_file_name": file_code.uuid_file_name,
Expand Down Expand Up @@ -668,6 +684,8 @@ async def get_file_detail(self, file_id: int):
is_text=is_text,
)

# 只有寄件文件展示授权后端,普通文件仍显示站点当前设置。
display_storage_type = await storage_type_for_share(file_code)
detail.update(
{
"filename": detail["name"],
Expand All @@ -678,7 +696,7 @@ async def get_file_detail(self, file_id: int):
"text_length": text_length,
"can_preview_text": is_text,
"can_download": can_download,
"storage_backend": settings.file_storage,
"storage_backend": display_storage_type,
"file_path": file_code.file_path,
"uuid_file_name": file_code.uuid_file_name,
"upload_id": file_code.upload_id,
Expand All @@ -690,7 +708,7 @@ async def get_file_detail(self, file_id: int):
"is_permanent": is_permanent,
},
"storage": {
"backend": settings.file_storage,
"backend": display_storage_type,
"file_path": file_code.file_path,
"uuid_file_name": file_code.uuid_file_name,
"file_hash": file_code.file_hash,
Expand All @@ -713,33 +731,12 @@ async def get_file_detail(self, file_id: int):
return detail

def _normalize_metadata_note(self, note: Optional[str]) -> str:
if note is None:
return ""
return str(note).strip()[: self.MAX_METADATA_NOTE_LENGTH]
# 与寄件管理共用规则,避免两个后台的备注长度和裁剪行为不一致。
return normalize_metadata_note(note)

def _normalize_metadata_tags(self, tags: Any) -> list[str]:
if not tags:
return []
if isinstance(tags, str):
tags = [tags]
elif not isinstance(tags, list):
return []

normalized_tags = []
seen_tags = set()
for raw_tag in tags:
tag = str(raw_tag).strip()
if not tag:
continue
tag = tag[: self.MAX_METADATA_TAG_LENGTH]
dedupe_key = tag.lower()
if dedupe_key in seen_tags:
continue
seen_tags.add(dedupe_key)
normalized_tags.append(tag)
if len(normalized_tags) >= self.MAX_METADATA_TAGS:
break
return normalized_tags
# 标签数量、长度和忽略大小写去重统一由公共模块维护。
return normalize_metadata_tags(tags)

def _normalize_file_metadata(self, metadata: Any) -> dict[str, Any]:
if not isinstance(metadata, dict):
Expand Down Expand Up @@ -1417,7 +1414,8 @@ async def download_file(self, file_id: int):
if file_code.text:
return APIResponse(detail=file_code.text)
else:
return response_from_download(await get_stored_download(file_code, self.file_storage))
# NAS 和普通下载沿用上游路径,寄件文件使用其授权后端。
return response_from_download(await get_stored_download(file_code, self._file_storage))

async def preview_file(self, file_id: int, max_chars: int = 4000):
max_chars = min(max(max_chars, 1), 20000)
Expand All @@ -1431,7 +1429,8 @@ async def preview_file(self, file_id: int, max_chars: int = 4000):
preview = content[:max_chars]
return {
"id": file_code.id,
"code": file_code.code,
"code": "" if file_code.is_private else file_code.code,
"is_private": file_code.is_private,
"name": f"{file_code.prefix}{file_code.suffix}",
"type": "text",
"content": preview,
Expand All @@ -1448,6 +1447,7 @@ async def share_local_file(self, item):
if not await local_file.exists():
raise HTTPException(status_code=404, detail="文件不存在")

# NAS 分享引用已有文件,不复制到上传目录,也不消耗上传配额。
expired_at, expired_count, used_count, code = await get_expire_info(
item.expire_value, item.expire_style
)
Expand Down
2 changes: 2 additions & 0 deletions apps/admin/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,7 @@ async def file_list(
health: str = "",
sort_by: str = "created_at",
sort_order: str = "desc",
delivery_id: Optional[int] = None,
file_service: FileService = Depends(get_file_service),
):
page = max(page, 1)
Expand All @@ -399,6 +400,7 @@ async def file_list(
health=health,
sort_by=sort_by,
sort_order=sort_order,
delivery_id=delivery_id,
)
return APIResponse(
detail={
Expand Down
28 changes: 28 additions & 0 deletions apps/base/metadata.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
"""文件与寄件管理共用的用户元数据归一化规则。"""

from typing import Any


def normalize_metadata_note(value: Any) -> str:
"""备注转为文本、去除首尾空白,并限制为 2000 个字符。"""
return "" if value is None else str(value).strip()[:2000]


def normalize_metadata_tags(value: Any) -> list[str]:
"""标签最多 12 个、单项 24 字符,并按忽略大小写规则去重。"""
if not value:
return []
if isinstance(value, str):
value = [value]
if not isinstance(value, list):
return []
result, seen = [], set()
for raw_tag in value:
tag = str(raw_tag).strip()[:24]
if not tag or tag.lower() in seen:
continue
seen.add(tag.lower())
result.append(tag)
if len(result) == 12:
break
return result
42 changes: 42 additions & 0 deletions apps/base/migrations/migrations_008.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
"""新增寄件口令与独立收件记录;保留既有分享表及其权限语义。"""

from tortoise import connections


async def migrate():
# 当前应用使用 SQLite;表定义与 Tortoise 模型保持一致,可重复执行。
await connections.get("default").execute_script("""
CREATE TABLE IF NOT EXISTS deliverycode (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
code_digest VARCHAR(64) NOT NULL UNIQUE,
name VARCHAR(100) NOT NULL,
owner_id VARCHAR(64) NOT NULL DEFAULT 'admin',
storage_type VARCHAR(20) NOT NULL,
target_path VARCHAR(200) NOT NULL,
expires_at TIMESTAMP NOT NULL,
max_uploads INT NOT NULL,
used_count INT NOT NULL DEFAULT 0,
reserved_count INT NOT NULL DEFAULT 0,
enabled INT NOT NULL DEFAULT 1,
deleted INT NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_deliverycode_owner ON deliverycode(owner_id);
CREATE TABLE IF NOT EXISTS deliveryfile (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
delivery_id INT NOT NULL,
owner_id VARCHAR(64) NOT NULL DEFAULT 'admin',
token VARCHAR(64) NOT NULL UNIQUE,
filename VARCHAR(255) NOT NULL DEFAULT '',
stored_name VARCHAR(255) NOT NULL DEFAULT '',
file_path VARCHAR(200) NOT NULL,
storage_type VARCHAR(20) NOT NULL,
size BIGINT NOT NULL DEFAULT 0,
status VARCHAR(20) NOT NULL DEFAULT 'pending',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_deliveryfile_code ON deliveryfile(delivery_id);
CREATE INDEX IF NOT EXISTS idx_deliveryfile_owner ON deliveryfile(owner_id);
CREATE INDEX IF NOT EXISTS idx_deliveryfile_status ON deliveryfile(status);
""")
11 changes: 11 additions & 0 deletions apps/base/migrations/migrations_009.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
"""保留管理员可查看的寄件码原文,既有摘要及口令有效性保持不变。"""

from tortoise import connections


async def migrate():
conn = connections.get("default")
# 可重复执行;旧数据保持 NULL,不能伪造或替换用户此前分发的口令。
columns = await conn.execute_query_dict("PRAGMA table_info(deliverycode)")
if not any(column["name"] == "code_value" for column in columns):
await conn.execute_script("ALTER TABLE deliverycode ADD COLUMN code_value VARCHAR(64) NULL;")
11 changes: 11 additions & 0 deletions apps/base/migrations/migrations_010.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
"""将新的寄件授权上传关联到普通取件记录,保留历史私有收件。"""

from tortoise import connections


async def migrate():
conn = connections.get("default")
columns = await conn.execute_query_dict("PRAGMA table_info(deliveryfile)")
if not any(column["name"] == "share_id" for column in columns):
await conn.execute_script("ALTER TABLE deliveryfile ADD COLUMN share_id INT NULL;")
await conn.execute_script("CREATE INDEX IF NOT EXISTS idx_deliveryfile_share_id ON deliveryfile(share_id);")
34 changes: 34 additions & 0 deletions apps/base/migrations/migrations_011.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
"""仅为寄件分享保存授权指定的实际存储后端。"""

from tortoise import connections


async def migrate():
"""幂等增加字段,并仅用寄件关联补全可验证的旧记录。"""
conn = connections.get("default")
tables = {
"filecodes": "storage_type",
}
for table, column in tables.items():
columns = await conn.execute_query_dict(f"PRAGMA table_info({table})")
if not any(item["name"] == column for item in columns):
await conn.execute_script(
f"ALTER TABLE {table} ADD COLUMN {column} VARCHAR(20) NULL;"
)

# 仅 DeliveryFile 的关联记录能证明旧分享的实际后端;其余历史数据保持未知。
await conn.execute_script(
"""
UPDATE filecodes
SET storage_type = (
SELECT storage_type FROM deliveryfile
WHERE deliveryfile.share_id = filecodes.id
LIMIT 1
)
WHERE storage_type IS NULL
AND EXISTS (
SELECT 1 FROM deliveryfile
WHERE deliveryfile.share_id = filecodes.id
);
"""
)
16 changes: 16 additions & 0 deletions apps/base/migrations/migrations_012.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
"""为寄件码补充管理备注、标签和授权版本,兼容已部署的 SQLite 数据库。"""

from tortoise import connections


async def migrate():
"""按列存在性增量迁移,重复启动不会修改已有数据。"""
conn = connections.get("default")
columns = await conn.execute_query_dict("PRAGMA table_info(deliverycode)")
names = {column["name"] for column in columns}
if "note" not in names:
await conn.execute_script("ALTER TABLE deliverycode ADD COLUMN note VARCHAR(2000) NOT NULL DEFAULT '';" )
if "tags" not in names:
await conn.execute_script("ALTER TABLE deliverycode ADD COLUMN tags JSON NOT NULL DEFAULT '[]';")
if "auth_version" not in names:
await conn.execute_script("ALTER TABLE deliverycode ADD COLUMN auth_version INT NOT NULL DEFAULT 1;")
Loading