Skip to content

Commit d362848

Browse files
author
dawnstamp
committed
fix(寄件): 合并上游并兼容本地分享的下载清理与配额
2 parents db72e9c + 82555c0 commit d362848

23 files changed

Lines changed: 576 additions & 209 deletions

‎.release-please-manifest.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
{
2-
".": "2.6.1"
2+
".": "2.7.0"
33
}

‎VERSION‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
2.6.1
1+
2.7.0

‎apps/admin/services.py‎

Lines changed: 101 additions & 97 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,34 @@
11
import asyncio
22
import hashlib
3-
import io
43
from pathlib import Path
5-
import os
6-
import time
7-
import uuid
84
from datetime import datetime, timedelta
95
from typing import Any, Optional
106

117
from core.response import APIResponse
12-
from core.storage import FileStorageInterface, StoredFile, storages
8+
from core.storage import FileStorageInterface, storages
139
from core.settings import (
1410
ADMIN_SESSION_EXPIRE_MAX,
1511
ADMIN_SESSION_EXPIRE_MIN,
1612
settings,
1713
)
1814
from apps.base.config import refresh_settings
19-
from apps.base.metadata import normalize_metadata_note, normalize_metadata_tags
20-
from apps.base.services import response_from_download, stored_file_of
15+
from apps.base.services import get_stored_download, response_from_download, stored_file_of
2116
from core.security import INTERNAL_CONFIG_KEYS, generate_jwt_secret
2217
from apps.base.models import DeliveryCode, DeliveryFile, FileCodes, KeyValue
18+
from apps.base.utils import get_expire_info
19+
from apps.base.local_share import (
20+
LOCAL_REF_MARKER,
21+
MAX_LIST_ENTRIES,
22+
format_local_ctime,
23+
get_local_root,
24+
is_local_ref,
25+
normalize_local_relpath,
26+
resolve_under_local,
27+
should_skip_storage_delete,
28+
)
29+
from apps.base.metadata import normalize_metadata_note, normalize_metadata_tags
2330
from apps.base.share_storage import remove_delivery_share, storage_for_share, storage_type_for_share
24-
from apps.base.utils import get_expire_info, get_file_path_name
25-
from apps.base.quota import release_storage, reserve_storage
2631
from fastapi import HTTPException
27-
from core.settings import data_root
2832
from core.utils import get_now, hash_password, is_password_hashed, validate_background_url
2933

3034
# KeyValue 里的 settings/activities/presets 都是整块 JSON 读-改-写;
@@ -103,7 +107,8 @@ async def _delete_file_code(self, file_code: FileCodes):
103107
# 寄件分享在两个管理入口使用相同撤销与清理逻辑,避免重复计费或遗留可用取件码。
104108
if await remove_delivery_share(file_code):
105109
return
106-
if file_code.text is None:
110+
# NAS 引用只删除分享记录,不能删除原始文件。
111+
if not should_skip_storage_delete(file_code):
107112
storage = await storage_for_share(file_code, self._file_storage)
108113
await storage.delete_file(stored_file_of(file_code))
109114
await KeyValue.filter(key=self._file_metadata_key(file_code.id)).delete()
@@ -514,7 +519,7 @@ async def list_files(
514519
"file_count": 0,
515520
"chunked_count": 0,
516521
**self._empty_health_summary(),
517-
"storage_used": sum(file_code.size for file_code in all_files),
522+
"storage_used": sum(file_code.size for file_code in all_files if not is_local_ref(file_code)),
518523
"used_count": sum(file_code.used_count for file_code in all_files),
519524
}
520525

@@ -619,6 +624,7 @@ async def _build_admin_file_item(
619624
"file_hash": file_code.file_hash,
620625
"is_chunked": file_code.is_chunked,
621626
"upload_id": file_code.upload_id,
627+
"is_local_ref": is_local_ref(file_code),
622628
}
623629
data.update(
624630
{
@@ -1411,8 +1417,8 @@ async def download_file(self, file_id: int):
14111417
if file_code.text:
14121418
return APIResponse(detail=file_code.text)
14131419
else:
1414-
storage = await storage_for_share(file_code, self._file_storage)
1415-
return response_from_download(await storage.get_file_response(stored_file_of(file_code)))
1420+
# 统一处理 NAS 引用路径及普通/寄件文件的存储快照。
1421+
return response_from_download(await get_stored_download(file_code, self._file_storage))
14161422

14171423
async def preview_file(self, file_id: int, max_chars: int = 4000):
14181424
max_chars = min(max(max_chars, 1), 20000)
@@ -1443,45 +1449,30 @@ async def share_local_file(self, item):
14431449
if not await local_file.exists():
14441450
raise HTTPException(status_code=404, detail="文件不存在")
14451451

1446-
reservation_token = f"local:{uuid.uuid4().hex}"
1447-
# 在读取和写入之间固定实际后端,避免管理端上传遇到设置切换而记录错位。
1448-
storage_type = settings.file_storage
1449-
storage = storages[storage_type]()
1450-
await reserve_storage(reservation_token, local_file.size, ttl_seconds=3600)
1451-
try:
1452-
data = await local_file.read() # bytes(read 内部用 with 关闭句柄)
1453-
expired_at, expired_count, used_count, code = await get_expire_info(
1454-
item.expire_value, item.expire_style
1455-
)
1456-
path, suffix, prefix, uuid_file_name, save_path = await get_file_path_name(
1457-
item
1458-
)
1459-
await storage.save_file(io.BytesIO(data), save_path)
1460-
try:
1461-
await FileCodes.create(
1462-
code=code,
1463-
prefix=prefix,
1464-
suffix=suffix,
1465-
uuid_file_name=uuid_file_name,
1466-
file_path=path,
1467-
size=local_file.size,
1468-
expired_at=expired_at,
1469-
expired_count=expired_count,
1470-
used_count=used_count,
1471-
# 管理端本地文件分享同样固定保存时的真实后端。
1472-
storage_type=storage_type,
1473-
)
1474-
except Exception:
1475-
await storage.delete_file(
1476-
StoredFile(file_path=path, uuid_file_name=uuid_file_name)
1477-
)
1478-
raise
1479-
finally:
1480-
await release_storage(reservation_token)
1481-
1452+
# NAS 分享引用已有文件,不复制到上传目录,也不消耗上传配额。
1453+
expired_at, expired_count, used_count, code = await get_expire_info(
1454+
item.expire_value, item.expire_style
1455+
)
1456+
name = local_file.name
1457+
suffix = Path(name).suffix
1458+
prefix = name[: len(name) - len(suffix)] if suffix else name
1459+
record = await FileCodes.create(
1460+
code=code,
1461+
prefix=prefix,
1462+
suffix=suffix,
1463+
uuid_file_name=local_file.file,
1464+
file_path=LOCAL_REF_MARKER,
1465+
storage_type="local",
1466+
size=local_file.size or 0,
1467+
expired_at=expired_at,
1468+
expired_count=expired_count,
1469+
used_count=used_count,
1470+
)
14821471
return {
14831472
"code": code,
1484-
"name": local_file.file,
1473+
"name": name,
1474+
"path": local_file.file,
1475+
"id": record.id,
14851476
}
14861477

14871478

@@ -1629,18 +1620,55 @@ async def update_config(self, data: dict):
16291620

16301621

16311622
class LocalFileService:
1632-
async def list_files(self):
1633-
files = []
1634-
if not os.path.exists(data_root / "local"):
1635-
os.makedirs(data_root / "local")
1636-
for file in os.listdir(data_root / "local"):
1637-
local_file = LocalFileClass(file)
1638-
files.append({
1639-
"file": local_file.file,
1640-
"ctime": local_file.ctime,
1641-
"size": local_file.size,
1642-
})
1643-
return files
1623+
async def list_files(self, path: str = ""):
1624+
relpath = normalize_local_relpath(path, allow_empty=True)
1625+
directory = resolve_under_local(relpath)
1626+
if not directory.exists() or not directory.is_dir():
1627+
raise HTTPException(status_code=404, detail="目录不存在")
1628+
1629+
root = get_local_root()
1630+
items = []
1631+
try:
1632+
children = list(directory.iterdir())
1633+
except OSError as exc:
1634+
raise HTTPException(status_code=500, detail="无法读取目录") from exc
1635+
1636+
children.sort(key=lambda p: (not p.is_dir(), p.name.lower()))
1637+
truncated = False
1638+
for child in children:
1639+
if len(items) >= MAX_LIST_ENTRIES:
1640+
truncated = True
1641+
break
1642+
try:
1643+
resolved = child.resolve()
1644+
resolved.relative_to(root)
1645+
except (OSError, ValueError):
1646+
continue
1647+
if not resolved.is_file() and not resolved.is_dir():
1648+
continue
1649+
child_rel = child.name if not relpath else f"{relpath}/{child.name}"
1650+
is_dir = resolved.is_dir()
1651+
items.append(
1652+
{
1653+
"file": child.name,
1654+
"name": child.name,
1655+
"path": child_rel,
1656+
"type": "dir" if is_dir else "file",
1657+
"ctime": format_local_ctime(resolved),
1658+
"size": None if is_dir else resolved.stat().st_size,
1659+
}
1660+
)
1661+
1662+
parent = ""
1663+
if relpath:
1664+
parent_path = Path(relpath).parent.as_posix()
1665+
parent = "" if parent_path == "." else parent_path
1666+
return {
1667+
"path": relpath,
1668+
"parent": parent,
1669+
"truncated": truncated,
1670+
"items": items,
1671+
}
16441672

16451673
async def delete_file(self, filename: str):
16461674
file = LocalFileClass(filename)
@@ -1652,39 +1680,13 @@ async def delete_file(self, filename: str):
16521680

16531681
class LocalFileClass:
16541682
def __init__(self, file):
1655-
# 仅允许 data/local 目录下的单层文件名,阻断路径穿越与绝对路径访问。
1656-
raw_name = str(file or "")
1657-
normalized = Path(raw_name).as_posix()
1658-
# 输入本身不得包含路径分隔符或绝对路径形态。
1659-
if (
1660-
not raw_name
1661-
or raw_name in {".", ".."}
1662-
or normalized in {".", ".."}
1663-
or "/" in normalized
1664-
or normalized.startswith("~")
1665-
or Path(raw_name).is_absolute()
1666-
or Path(raw_name).name != raw_name
1667-
):
1668-
raise HTTPException(status_code=400, detail="非法文件名")
1669-
1670-
safe_name = Path(raw_name).name
1671-
if not safe_name or safe_name in {".", ".."}:
1672-
raise HTTPException(status_code=400, detail="非法文件名")
1673-
1674-
local_root = (data_root / "local").resolve()
1675-
candidate = (local_root / safe_name).resolve()
1676-
try:
1677-
candidate.relative_to(local_root)
1678-
except ValueError:
1679-
raise HTTPException(status_code=400, detail="非法文件路径")
1680-
1681-
self.file = safe_name
1682-
self.path = candidate
1683+
relpath = normalize_local_relpath(file)
1684+
self.file = relpath
1685+
self.name = Path(relpath).name
1686+
self.path = resolve_under_local(relpath)
16831687
if self.path.is_file():
1684-
self.ctime = time.strftime(
1685-
"%Y-%m-%d %H:%M:%S", time.localtime(os.path.getctime(self.path))
1686-
)
1687-
self.size = os.path.getsize(self.path)
1688+
self.ctime = format_local_ctime(self.path)
1689+
self.size = self.path.stat().st_size
16881690
else:
16891691
self.ctime = None
16901692
self.size = None
@@ -1694,11 +1696,13 @@ async def read(self) -> bytes:
16941696
return fh.read()
16951697

16961698
async def write(self, data):
1697-
with open(self.path, "w") as f:
1699+
with open(self.path, "wb") as f:
16981700
f.write(data)
16991701

17001702
async def delete(self):
1701-
os.remove(self.path)
1703+
if not self.path.is_file():
1704+
raise HTTPException(status_code=404, detail="文件不存在")
1705+
self.path.unlink()
17021706

17031707
async def exists(self):
17041708
return self.path.is_file()

‎apps/admin/views.py‎

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
)
3232
from core.response import APIResponse
3333
from apps.base.models import FileCodes, KeyValue
34+
from apps.base.quota import owned_storage_queryset
3435
from tortoise.expressions import Q
3536
from tortoise.functions import Count, Sum
3637
from apps.admin.dependencies import (
@@ -144,7 +145,7 @@ async def dashboard(file_service: FileService = Depends(get_file_service)):
144145
# 健康摘要依赖逐行 rules engine(_build_file_status_insights),保留一次遍历。
145146
total_files = await FileCodes.all().count()
146147
expired_count = await FileCodes.filter(_expired_predicate(now)).count()
147-
all_size = await _sum_size(FileCodes.all())
148+
all_size = await _sum_size(owned_storage_queryset())
148149
used_count = (
149150
await FileCodes.all().annotate(total=Sum("used_count")).values("total")
150151
)[0]["total"] or 0
@@ -154,10 +155,14 @@ async def dashboard(file_service: FileService = Depends(get_file_service)):
154155
created_at__gte=yesterday_start, created_at__lte=yesterday_end
155156
).count()
156157
yesterday_size = await _sum_size(
157-
FileCodes.filter(created_at__gte=yesterday_start, created_at__lte=yesterday_end)
158+
owned_storage_queryset(
159+
FileCodes.filter(created_at__gte=yesterday_start, created_at__lte=yesterday_end)
160+
)
158161
)
159162
today_count = await FileCodes.filter(created_at__gte=today_start).count()
160-
today_size = await _sum_size(FileCodes.filter(created_at__gte=today_start))
163+
today_size = await _sum_size(
164+
owned_storage_queryset(FileCodes.filter(created_at__gte=today_start))
165+
)
161166

162167
suffix_rows = (
163168
await FileCodes.filter(text__isnull=True)
@@ -574,9 +579,10 @@ async def file_preview(
574579

575580
@admin_api.get("/local/lists")
576581
async def get_local_lists(
582+
path: str = "",
577583
local_file_service: LocalFileService = Depends(get_local_file_service),
578584
):
579-
files = await local_file_service.list_files()
585+
files = await local_file_service.list_files(path)
580586
return APIResponse(detail=files)
581587

582588

0 commit comments

Comments
 (0)