Skip to content

Commit 93a30a2

Browse files
committed
feat: mint extract codes from data/local without re-upload
Admin can browse NAS files under data/local including subfolders and share them in place. Shares use file_path=local-ref, skip storage quota, and never delete the original file on expire.
1 parent 3c135eb commit 93a30a2

14 files changed

Lines changed: 546 additions & 192 deletions

‎apps/admin/services.py‎

Lines changed: 96 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,32 @@
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.services import response_from_download, stored_file_of
15+
from apps.base.services import get_stored_download, response_from_download, stored_file_of
2016
from core.security import INTERNAL_CONFIG_KEYS, generate_jwt_secret
2117
from apps.base.models import FileCodes, KeyValue
22-
from apps.base.utils import get_expire_info, get_file_path_name
23-
from apps.base.quota import release_storage, reserve_storage
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+
)
2429
from fastapi import HTTPException
25-
from core.settings import data_root
2630
from core.utils import get_now, hash_password, is_password_hashed, validate_background_url
2731

2832
# KeyValue 里的 settings/activities/presets 都是整块 JSON 读-改-写;
@@ -98,7 +102,7 @@ def _file_metadata_key(self, file_id: int) -> str:
98102
return f"{self.FILE_METADATA_KEY_PREFIX}{file_id}"
99103

100104
async def _delete_file_code(self, file_code: FileCodes):
101-
if file_code.text is None:
105+
if not should_skip_storage_delete(file_code):
102106
await self.file_storage.delete_file(stored_file_of(file_code))
103107
await KeyValue.filter(key=self._file_metadata_key(file_code.id)).delete()
104108
await file_code.delete()
@@ -498,7 +502,7 @@ async def list_files(
498502
"file_count": 0,
499503
"chunked_count": 0,
500504
**self._empty_health_summary(),
501-
"storage_used": sum(file_code.size for file_code in all_files),
505+
"storage_used": sum(file_code.size for file_code in all_files if not is_local_ref(file_code)),
502506
"used_count": sum(file_code.used_count for file_code in all_files),
503507
}
504508

@@ -603,6 +607,7 @@ async def _build_admin_file_item(
603607
"file_hash": file_code.file_hash,
604608
"is_chunked": file_code.is_chunked,
605609
"upload_id": file_code.upload_id,
610+
"is_local_ref": is_local_ref(file_code),
606611
}
607612
data.update(
608613
{
@@ -1412,7 +1417,7 @@ async def download_file(self, file_id: int):
14121417
if file_code.text:
14131418
return APIResponse(detail=file_code.text)
14141419
else:
1415-
return response_from_download(await self.file_storage.get_file_response(stored_file_of(file_code)))
1420+
return response_from_download(await get_stored_download(file_code, self.file_storage))
14161421

14171422
async def preview_file(self, file_id: int, max_chars: int = 4000):
14181423
max_chars = min(max(max_chars, 1), 20000)
@@ -1443,40 +1448,28 @@ async def share_local_file(self, item):
14431448
if not await local_file.exists():
14441449
raise HTTPException(status_code=404, detail="文件不存在")
14451450

1446-
reservation_token = f"local:{uuid.uuid4().hex}"
1447-
await reserve_storage(reservation_token, local_file.size, ttl_seconds=3600)
1448-
try:
1449-
data = await local_file.read() # bytes(read 内部用 with 关闭句柄)
1450-
expired_at, expired_count, used_count, code = await get_expire_info(
1451-
item.expire_value, item.expire_style
1452-
)
1453-
path, suffix, prefix, uuid_file_name, save_path = await get_file_path_name(
1454-
item
1455-
)
1456-
await self.file_storage.save_file(io.BytesIO(data), save_path)
1457-
try:
1458-
await FileCodes.create(
1459-
code=code,
1460-
prefix=prefix,
1461-
suffix=suffix,
1462-
uuid_file_name=uuid_file_name,
1463-
file_path=path,
1464-
size=local_file.size,
1465-
expired_at=expired_at,
1466-
expired_count=expired_count,
1467-
used_count=used_count,
1468-
)
1469-
except Exception:
1470-
await self.file_storage.delete_file(
1471-
StoredFile(file_path=path, uuid_file_name=uuid_file_name)
1472-
)
1473-
raise
1474-
finally:
1475-
await release_storage(reservation_token)
1476-
1451+
expired_at, expired_count, used_count, code = await get_expire_info(
1452+
item.expire_value, item.expire_style
1453+
)
1454+
name = local_file.name
1455+
suffix = Path(name).suffix
1456+
prefix = name[: len(name) - len(suffix)] if suffix else name
1457+
record = await FileCodes.create(
1458+
code=code,
1459+
prefix=prefix,
1460+
suffix=suffix,
1461+
uuid_file_name=local_file.file,
1462+
file_path=LOCAL_REF_MARKER,
1463+
size=local_file.size or 0,
1464+
expired_at=expired_at,
1465+
expired_count=expired_count,
1466+
used_count=used_count,
1467+
)
14771468
return {
14781469
"code": code,
1479-
"name": local_file.file,
1470+
"name": name,
1471+
"path": local_file.file,
1472+
"id": record.id,
14801473
}
14811474

14821475

@@ -1587,18 +1580,55 @@ async def update_config(self, data: dict):
15871580

15881581

15891582
class LocalFileService:
1590-
async def list_files(self):
1591-
files = []
1592-
if not os.path.exists(data_root / "local"):
1593-
os.makedirs(data_root / "local")
1594-
for file in os.listdir(data_root / "local"):
1595-
local_file = LocalFileClass(file)
1596-
files.append({
1597-
"file": local_file.file,
1598-
"ctime": local_file.ctime,
1599-
"size": local_file.size,
1600-
})
1601-
return files
1583+
async def list_files(self, path: str = ""):
1584+
relpath = normalize_local_relpath(path, allow_empty=True)
1585+
directory = resolve_under_local(relpath)
1586+
if not directory.exists() or not directory.is_dir():
1587+
raise HTTPException(status_code=404, detail="目录不存在")
1588+
1589+
root = get_local_root()
1590+
items = []
1591+
try:
1592+
children = list(directory.iterdir())
1593+
except OSError as exc:
1594+
raise HTTPException(status_code=500, detail="无法读取目录") from exc
1595+
1596+
children.sort(key=lambda p: (not p.is_dir(), p.name.lower()))
1597+
truncated = False
1598+
for child in children:
1599+
if len(items) >= MAX_LIST_ENTRIES:
1600+
truncated = True
1601+
break
1602+
try:
1603+
resolved = child.resolve()
1604+
resolved.relative_to(root)
1605+
except (OSError, ValueError):
1606+
continue
1607+
if not resolved.is_file() and not resolved.is_dir():
1608+
continue
1609+
child_rel = child.name if not relpath else f"{relpath}/{child.name}"
1610+
is_dir = resolved.is_dir()
1611+
items.append(
1612+
{
1613+
"file": child.name,
1614+
"name": child.name,
1615+
"path": child_rel,
1616+
"type": "dir" if is_dir else "file",
1617+
"ctime": format_local_ctime(resolved),
1618+
"size": None if is_dir else resolved.stat().st_size,
1619+
}
1620+
)
1621+
1622+
parent = ""
1623+
if relpath:
1624+
parent_path = Path(relpath).parent.as_posix()
1625+
parent = "" if parent_path == "." else parent_path
1626+
return {
1627+
"path": relpath,
1628+
"parent": parent,
1629+
"truncated": truncated,
1630+
"items": items,
1631+
}
16021632

16031633
async def delete_file(self, filename: str):
16041634
file = LocalFileClass(filename)
@@ -1610,39 +1640,13 @@ async def delete_file(self, filename: str):
16101640

16111641
class LocalFileClass:
16121642
def __init__(self, file):
1613-
# 仅允许 data/local 目录下的单层文件名,阻断路径穿越与绝对路径访问。
1614-
raw_name = str(file or "")
1615-
normalized = Path(raw_name).as_posix()
1616-
# 输入本身不得包含路径分隔符或绝对路径形态。
1617-
if (
1618-
not raw_name
1619-
or raw_name in {".", ".."}
1620-
or normalized in {".", ".."}
1621-
or "/" in normalized
1622-
or normalized.startswith("~")
1623-
or Path(raw_name).is_absolute()
1624-
or Path(raw_name).name != raw_name
1625-
):
1626-
raise HTTPException(status_code=400, detail="非法文件名")
1627-
1628-
safe_name = Path(raw_name).name
1629-
if not safe_name or safe_name in {".", ".."}:
1630-
raise HTTPException(status_code=400, detail="非法文件名")
1631-
1632-
local_root = (data_root / "local").resolve()
1633-
candidate = (local_root / safe_name).resolve()
1634-
try:
1635-
candidate.relative_to(local_root)
1636-
except ValueError:
1637-
raise HTTPException(status_code=400, detail="非法文件路径")
1638-
1639-
self.file = safe_name
1640-
self.path = candidate
1643+
relpath = normalize_local_relpath(file)
1644+
self.file = relpath
1645+
self.name = Path(relpath).name
1646+
self.path = resolve_under_local(relpath)
16411647
if self.path.is_file():
1642-
self.ctime = time.strftime(
1643-
"%Y-%m-%d %H:%M:%S", time.localtime(os.path.getctime(self.path))
1644-
)
1645-
self.size = os.path.getsize(self.path)
1648+
self.ctime = format_local_ctime(self.path)
1649+
self.size = self.path.stat().st_size
16461650
else:
16471651
self.ctime = None
16481652
self.size = None
@@ -1652,11 +1656,13 @@ async def read(self) -> bytes:
16521656
return fh.read()
16531657

16541658
async def write(self, data):
1655-
with open(self.path, "w") as f:
1659+
with open(self.path, "wb") as f:
16561660
f.write(data)
16571661

16581662
async def delete(self):
1659-
os.remove(self.path)
1663+
if not self.path.is_file():
1664+
raise HTTPException(status_code=404, detail="文件不存在")
1665+
self.path.unlink()
16601666

16611667
async def exists(self):
16621668
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)
@@ -567,9 +572,10 @@ async def file_preview(
567572

568573
@admin_api.get("/local/lists")
569574
async def get_local_lists(
575+
path: str = "",
570576
local_file_service: LocalFileService = Depends(get_local_file_service),
571577
):
572-
files = await local_file_service.list_files()
578+
files = await local_file_service.list_files(path)
573579
return APIResponse(detail=files)
574580

575581

0 commit comments

Comments
 (0)