Skip to content

Commit 95e84fb

Browse files
author
dawnstamp
committed
fix(寄件): 收敛授权上传范围并修正续期与收件关联
复用主干上传和存储驱动,移除独立上传、影子文件表及旧主题后台。仅为寄件保留后端关联、原子次数预占和容量校验;S3 寄件使用代理上传,拒绝并清理旧直传会话。 移除摘要双存,缺少原文的旧码停用并保留收件关联。恢复普通上传、2023 配置和公共驱动的上游行为。 验证:后端完整回归 134 项通过、2 项跳过,13 个子测试通过;三种存储寄件业务链与多文件 ZIP 投递验证通过。
1 parent d362848 commit 95e84fb

38 files changed

Lines changed: 1056 additions & 1377 deletions

‎apps/admin/dependencies.py‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@
1414
ADMIN_SESSION_EXPIRE_MIN,
1515
settings,
1616
)
17-
from apps.admin.services import FileService, ConfigService, LocalFileService
1817

1918

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

174173

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

178179

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

182185

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

‎apps/admin/services.py‎

Lines changed: 17 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
from apps.base.config import refresh_settings
1515
from apps.base.services import get_stored_download, response_from_download, stored_file_of
1616
from core.security import INTERNAL_CONFIG_KEYS, generate_jwt_secret
17-
from apps.base.models import DeliveryCode, DeliveryFile, FileCodes, KeyValue
17+
from apps.base.models import DeliveryCode, FileCodes, KeyValue
1818
from apps.base.utils import get_expire_info
1919
from apps.base.local_share import (
2020
LOCAL_REF_MARKER,
@@ -27,7 +27,7 @@
2727
should_skip_storage_delete,
2828
)
2929
from apps.base.metadata import normalize_metadata_note, normalize_metadata_tags
30-
from apps.base.share_storage import remove_delivery_share, storage_for_share, storage_type_for_share
30+
from apps.base.share_storage import storage_for_share, storage_type_for_share
3131
from fastapi import HTTPException
3232
from core.utils import get_now, hash_password, is_password_hashed, validate_background_url
3333

@@ -104,9 +104,7 @@ def _file_metadata_key(self, file_id: int) -> str:
104104
return f"{self.FILE_METADATA_KEY_PREFIX}{file_id}"
105105

106106
async def _delete_file_code(self, file_code: FileCodes):
107-
# 寄件分享在两个管理入口使用相同撤销与清理逻辑,避免重复计费或遗留可用取件码。
108-
if await remove_delivery_share(file_code):
109-
return
107+
# 寄件文件与普通文件共用删除流程,失败时保留记录供重试。
110108
# NAS 引用只删除分享记录,不能删除原始文件。
111109
if not should_skip_storage_delete(file_code):
112110
storage = await storage_for_share(file_code, self._file_storage)
@@ -227,7 +225,8 @@ async def update_file(
227225
raise HTTPException(status_code=404, detail="文件不存在")
228226

229227
update_data: dict[str, Any] = {}
230-
if code is not None and code != file_code.code:
228+
# 历史私有文件没有公开口令,普通编辑不能改变其内部标识或公开权限。
229+
if code is not None and not file_code.is_private and code != file_code.code:
231230
if await FileCodes.filter(code=code).first():
232231
raise HTTPException(status_code=400, detail="code已存在")
233232
update_data["code"] = code
@@ -502,12 +501,9 @@ async def list_files(
502501
query = FileCodes.all()
503502
if delivery_id is not None:
504503
# 收件列表复用文件管理的数据与操作,只限定当前管理员选中的寄件码。
505-
if not await DeliveryCode.filter(id=delivery_id, owner_id="admin").exists():
504+
if not await DeliveryCode.filter(id=delivery_id).exists():
506505
raise HTTPException(404, "寄件码不存在")
507-
share_ids = await DeliveryFile.filter(
508-
delivery_id=delivery_id, owner_id="admin", status="shared"
509-
).values_list("share_id", flat=True)
510-
query = query.filter(id__in=share_ids)
506+
query = query.filter(delivery_id=delivery_id)
511507
all_files = await query
512508
now = await get_now()
513509
enriched_files = []
@@ -610,7 +606,8 @@ async def _build_admin_file_item(
610606
)
611607
data = {
612608
"id": file_code.id,
613-
"code": file_code.code,
609+
"code": "" if file_code.is_private else file_code.code,
610+
"is_private": file_code.is_private,
614611
"prefix": file_code.prefix,
615612
"suffix": file_code.suffix,
616613
"uuid_file_name": file_code.uuid_file_name,
@@ -685,10 +682,8 @@ async def get_file_detail(self, file_id: int):
685682
is_text=is_text,
686683
)
687684

688-
# 详情展示记录实际后端;历史普通文件没有可靠来源时显示 unknown。
689-
actual_storage_type = await storage_type_for_share(file_code)
690-
# 未知来源以 NULL 交给前端本地化,不能直接输出英文文案。
691-
display_storage_type = actual_storage_type
685+
# 只有寄件文件展示授权后端,普通文件仍显示站点当前设置。
686+
display_storage_type = await storage_type_for_share(file_code)
692687
detail.update(
693688
{
694689
"filename": detail["name"],
@@ -1417,7 +1412,7 @@ async def download_file(self, file_id: int):
14171412
if file_code.text:
14181413
return APIResponse(detail=file_code.text)
14191414
else:
1420-
# 统一处理 NAS 引用路径及普通/寄件文件的存储快照。
1415+
# NAS 和普通下载沿用上游路径,寄件文件使用其授权后端。
14211416
return response_from_download(await get_stored_download(file_code, self._file_storage))
14221417

14231418
async def preview_file(self, file_id: int, max_chars: int = 4000):
@@ -1432,7 +1427,8 @@ async def preview_file(self, file_id: int, max_chars: int = 4000):
14321427
preview = content[:max_chars]
14331428
return {
14341429
"id": file_code.id,
1435-
"code": file_code.code,
1430+
"code": "" if file_code.is_private else file_code.code,
1431+
"is_private": file_code.is_private,
14361432
"name": f"{file_code.prefix}{file_code.suffix}",
14371433
"type": "text",
14381434
"content": preview,
@@ -1462,7 +1458,6 @@ async def share_local_file(self, item):
14621458
suffix=suffix,
14631459
uuid_file_name=local_file.file,
14641460
file_path=LOCAL_REF_MARKER,
1465-
storage_type="local",
14661461
size=local_file.size or 0,
14671462
expired_at=expired_at,
14681463
expired_count=expired_count,
@@ -1477,20 +1472,6 @@ async def share_local_file(self, item):
14771472

14781473

14791474
class ConfigService:
1480-
# 2023 设置页仍使用迁移前的字段名;仅在旧管理接口边界转换,存储保持 snake_case。
1481-
LEGACY_CONFIG_FIELDS = {
1482-
"errorCount": "error_count",
1483-
"errorMinute": "error_minute",
1484-
"expireStyle": "expire_style",
1485-
"openUpload": "open_upload",
1486-
"robotsText": "robots_text",
1487-
"showAdminAddr": "show_admin_addr",
1488-
"themesChoices": "themes_choices",
1489-
"themesSelect": "themes_select",
1490-
"uploadCount": "upload_count",
1491-
"uploadMinute": "upload_minute",
1492-
"uploadSize": "upload_size",
1493-
}
14941475
INT_FIELDS = {
14951476
"admin_session_expire",
14961477
"enable_chunk",
@@ -1514,45 +1495,22 @@ class ConfigService:
15141495
}
15151496
FLOAT_FIELDS = {"opacity"}
15161497

1517-
def get_config(self, *, legacy: bool = False):
1498+
def get_config(self):
15181499
config = dict(settings.items())
15191500
config["admin_token"] = ""
15201501
for key in INTERNAL_CONFIG_KEYS:
15211502
config.pop(key, None)
1522-
if legacy:
1523-
# 每个配置只返回一种键名,避免旧页面整表提交时携带两个互相冲突的值。
1524-
for old_key, current_key in self.LEGACY_CONFIG_FIELDS.items():
1525-
config[old_key] = config.pop(current_key)
15261503
return config
15271504

15281505
async def update_config(self, data: dict):
15291506
current_config = dict(settings.items())
15301507
next_config = dict(current_config)
1531-
# 必须在白名单过滤前转换,否则旧主题保存成功但主题、上传限制等实际未更新。
1532-
normalized_data = dict(data)
1533-
for old_key, current_key in self.LEGACY_CONFIG_FIELDS.items():
1534-
if old_key not in normalized_data:
1535-
continue
1536-
value = normalized_data.pop(old_key)
1537-
if current_key in normalized_data and normalized_data[current_key] != value:
1538-
raise HTTPException(status_code=400, detail=f"{current_key} 配置值冲突")
1539-
normalized_data[current_key] = value
15401508
update_data = {
15411509
key: value
1542-
for key, value in normalized_data.items()
1543-
if key in settings.default_config
1544-
and key not in INTERNAL_CONFIG_KEYS
1545-
and key != "themes_choices" # 主题清单由程序维护,兼容旧字段时也不能允许客户端改写。
1510+
for key, value in data.items()
1511+
if key in settings.default_config and key not in INTERNAL_CONFIG_KEYS
15461512
}
15471513

1548-
# 与寄件目录采用相同规则,允许空前缀表示普通上传的默认日期目录。
1549-
if "storage_path" in update_data:
1550-
from core.path_validation import validate_storage_directory
1551-
try:
1552-
update_data["storage_path"] = validate_storage_directory(update_data["storage_path"], allow_empty=True)
1553-
except ValueError as exc:
1554-
raise HTTPException(422, str(exc)) from None
1555-
15561514
admin_token = update_data.get("admin_token")
15571515
admin_password_changed = False
15581516
if admin_token is None or admin_token == "":

‎apps/admin/views.py‎

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -532,10 +532,7 @@ async def file_view_presets_delete_post(
532532
async def get_config(
533533
config_service: ConfigService = Depends(get_config_service),
534534
):
535-
# 与实际返回的主题保持一致;2024 和公共 API 继续使用标准字段,2023 设置页使用旧字段。
536-
from apps.base.pages import resolve_theme_root
537-
538-
return APIResponse(detail=config_service.get_config(legacy=resolve_theme_root().name == "2023"))
535+
return APIResponse(detail=config_service.get_config())
539536

540537

541538
@admin_api.patch("/config/update")
@@ -545,8 +542,6 @@ async def update_config(
545542
file_service: FileService = Depends(get_file_service),
546543
):
547544
data.pop("themes_choices", None)
548-
# 旧主题会整表提交配置,同样剔除只读主题列表。
549-
data.pop("themesChoices", None)
550545
await config_service.update_config(data)
551546
await file_service.record_admin_activity(
552547
action="config.update",

‎apps/base/migrations/migrations_011.py‎

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
"""为普通文件及上传会话保存实际存储后端快照。"""
1+
"""仅为寄件分享保存授权指定的实际存储后端。"""
22

33
from tortoise import connections
44

@@ -8,8 +8,6 @@ async def migrate():
88
conn = connections.get("default")
99
tables = {
1010
"filecodes": "storage_type",
11-
"uploadchunk": "storage_type",
12-
"presignuploadsession": "storage_type",
1311
}
1412
for table, column in tables.items():
1513
columns = await conn.execute_query_dict(f"PRAGMA table_info({table})")
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
"""收件归入普通文件表;旧私有文件保持私有,未完成上传迁入短期容量预留。"""
2+
3+
import os
4+
import uuid
5+
from tortoise.transactions import in_transaction
6+
7+
8+
async def migrate():
9+
# 整体事务保证迁移失败可以回滚,影子表仅在全部记录转移成功后移除。
10+
async with in_transaction() as conn:
11+
additions = {
12+
"filecodes": {"delivery_id": "INT NULL", "is_private": "INT NOT NULL DEFAULT 0"},
13+
"storagereservation": {
14+
"delivery_id": "INT NULL", "auth_version": "INT NOT NULL DEFAULT 1",
15+
"status": "VARCHAR(20) NOT NULL DEFAULT 'pending'",
16+
"filename": "VARCHAR(255) NOT NULL DEFAULT ''",
17+
"stored_name": "VARCHAR(255) NOT NULL DEFAULT ''",
18+
"file_path": "VARCHAR(255) NOT NULL DEFAULT ''",
19+
"storage_type": "VARCHAR(20) NULL",
20+
},
21+
}
22+
for table, fields in additions.items():
23+
names = {row["name"] for row in await conn.execute_query_dict(f"PRAGMA table_info({table})")}
24+
for name, declaration in fields.items():
25+
if name not in names:
26+
await conn.execute_query(f"ALTER TABLE {table} ADD COLUMN {name} {declaration}")
27+
await conn.execute_query("CREATE INDEX IF NOT EXISTS idx_filecodes_delivery_id ON filecodes(delivery_id)")
28+
await conn.execute_query("CREATE INDEX IF NOT EXISTS idx_reservation_delivery_id ON storagereservation(delivery_id)")
29+
exists = await conn.execute_query_dict("SELECT name FROM sqlite_master WHERE type='table' AND name='deliveryfile'")
30+
if not exists:
31+
return
32+
# 分批读取避免升级时将全部文件记录载入内存。
33+
cursor = 0
34+
while True:
35+
rows = await conn.execute_query_dict("SELECT * FROM deliveryfile WHERE id > ? ORDER BY id LIMIT 100", [cursor])
36+
if not rows:
37+
break
38+
for row in rows:
39+
cursor = row["id"]
40+
if row["status"] == "deleted":
41+
continue
42+
code_id = row["delivery_id"]
43+
# 旧版可能已物理删除耗尽口令;补只读历史壳以保留按码查收件的入口。
44+
await conn.execute_query(
45+
"INSERT OR IGNORE INTO deliverycode (id, code_digest, name, storage_type, target_path, expires_at, max_uploads, enabled, deleted) "
46+
"VALUES (?, ?, ?, ?, '', CURRENT_TIMESTAMP, 1, 0, 0)",
47+
[code_id, uuid.uuid4().hex, "历史寄件(授权已撤销)", row["storage_type"]],
48+
)
49+
if row.get("share_id") is not None:
50+
await conn.execute_query(
51+
"UPDATE filecodes SET delivery_id = ?, storage_type = ?, upload_id = ? WHERE id = ?",
52+
[code_id, row["storage_type"], row["token"], row["share_id"]],
53+
)
54+
elif row["status"] == "stored":
55+
prefix, suffix = os.path.splitext(row["filename"])
56+
await conn.execute_query(
57+
"INSERT INTO filecodes (code, prefix, suffix, uuid_file_name, file_path, size, expired_count, used_count, is_chunked, created_at, storage_type, delivery_id, is_private, upload_id) "
58+
"VALUES (?, ?, ?, ?, ?, ?, -1, 0, 0, ?, ?, ?, 1, ?)",
59+
[uuid.uuid4().hex, prefix, suffix, row["stored_name"], row["file_path"], row["size"], row["created_at"], row["storage_type"], code_id, row["token"]],
60+
)
61+
elif row["status"] in {"pending", "finalizing", "cleanup"}:
62+
# 升级前未完成的上传统一取消并清理,不允许旧会话跨模型继续提交。
63+
await conn.execute_query("DELETE FROM storagereservation WHERE token IN (?, ?, ?)",
64+
["delivery:" + row["token"], "chunk:" + row["token"], "presign:" + row["token"]])
65+
await conn.execute_query(
66+
"INSERT OR IGNORE INTO storagereservation (token, size, expires_at, delivery_id, status, filename, stored_name, file_path, storage_type) "
67+
"VALUES (?, ?, CURRENT_TIMESTAMP, ?, 'cleanup', ?, ?, ?, ?)",
68+
[row["token"], row["size"], code_id, row["filename"], row["stored_name"], row["file_path"], row["storage_type"]],
69+
)
70+
await conn.execute_query("UPDATE deliverycode SET reserved_count = 0")
71+
await conn.execute_query("UPDATE deliverycode SET enabled = 0 WHERE used_count >= max_uploads OR deleted = 1")
72+
await conn.execute_query("DROP TABLE deliveryfile")
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
"""移除重复口令摘要及预设账号归属;缺少原文的旧授权停用但保留收件关联。"""
2+
3+
from tortoise.transactions import in_transaction
4+
5+
6+
async def migrate():
7+
# SQLite 不能直接删除带唯一约束的旧列,使用事务内重建保留主键及全部业务字段。
8+
async with in_transaction() as conn:
9+
columns = {row["name"] for row in await conn.execute_query_dict("PRAGMA table_info(deliverycode)")}
10+
if "code_digest" not in columns:
11+
return
12+
# 异常旧数据先中止升级,不能猜测同一口令应该属于哪条授权;错误不输出口令。
13+
duplicates = await conn.execute_query_dict(
14+
"SELECT MIN(id) AS first_id FROM deliverycode "
15+
"WHERE code_value IS NOT NULL AND code_value != '' GROUP BY code_value HAVING COUNT(*) > 1"
16+
)
17+
if duplicates:
18+
ids = ", ".join(str(row["first_id"]) for row in duplicates)
19+
raise RuntimeError("寄件码原文存在重复,请先为相关记录重新设置不同口令,首条记录 ID:" + ids)
20+
await conn.execute_query('''
21+
CREATE TABLE deliverycode_without_digest (
22+
id INTEGER PRIMARY KEY AUTOINCREMENT,
23+
code_value VARCHAR(64) NULL UNIQUE,
24+
auth_version INT NOT NULL DEFAULT 1,
25+
name VARCHAR(100) NOT NULL,
26+
note VARCHAR(2000) NOT NULL DEFAULT '',
27+
tags JSON NOT NULL DEFAULT '[]',
28+
storage_type VARCHAR(20) NOT NULL,
29+
target_path VARCHAR(200) NOT NULL,
30+
expires_at TIMESTAMP NOT NULL,
31+
max_uploads INT NOT NULL,
32+
used_count INT NOT NULL DEFAULT 0,
33+
reserved_count INT NOT NULL DEFAULT 0,
34+
enabled INT NOT NULL DEFAULT 1,
35+
deleted INT NOT NULL DEFAULT 0,
36+
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
37+
)
38+
''')
39+
await conn.execute_query('''
40+
INSERT INTO deliverycode_without_digest (
41+
id, code_value, auth_version, name, note, tags, storage_type, target_path,
42+
expires_at, max_uploads, used_count, reserved_count, enabled, deleted, created_at
43+
)
44+
SELECT id, NULLIF(code_value, ''),
45+
auth_version + CASE WHEN code_value IS NULL OR code_value = '' THEN 1 ELSE 0 END,
46+
name, note, tags, storage_type, target_path, expires_at, max_uploads,
47+
used_count, reserved_count,
48+
CASE WHEN code_value IS NULL OR code_value = '' THEN 0 ELSE enabled END,
49+
deleted, created_at
50+
FROM deliverycode
51+
''')
52+
# ID 保持不变;已存在的 FileCodes.delivery_id 以及历史计数不会丢失。
53+
await conn.execute_query("DROP TABLE deliverycode")
54+
await conn.execute_query("ALTER TABLE deliverycode_without_digest RENAME TO deliverycode")

0 commit comments

Comments
 (0)