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 .services import response_from_download , stored_file_of
15+ from apps .base .services import get_stored_download , response_from_download , stored_file_of
2016from core .security import INTERNAL_CONFIG_KEYS , generate_jwt_secret
2117from 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+ )
2429from fastapi import HTTPException
25- from core .settings import data_root
2630from 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
15891582class 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
16111641class 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 ()
0 commit comments