11import asyncio
22import hashlib
3- import io
43from pathlib import Path
5- import os
6- import time
7- import uuid
84from datetime import datetime , timedelta
95from typing import Any , Optional
106
117from core .response import APIResponse
12- from core .storage import FileStorageInterface , StoredFile , storages
8+ from core .storage import FileStorageInterface , storages
139from core .settings import (
1410 ADMIN_SESSION_EXPIRE_MAX ,
1511 ADMIN_SESSION_EXPIRE_MIN ,
1612 settings ,
1713)
1814from 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
2116from core .security import INTERNAL_CONFIG_KEYS , generate_jwt_secret
2217from 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
2330from 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
2631from fastapi import HTTPException
27- from core .settings import data_root
2832from 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
16311622class 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
16531681class 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 ()
0 commit comments