Skip to content

Commit 8d7d856

Browse files
committed
fix: harden share retrieval and admin visibility (#482, #480)
1 parent 36300ef commit 8d7d856

8 files changed

Lines changed: 296 additions & 24 deletions

File tree

apps/base/utils.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ async def get_expire_info(
9090

9191

9292
def get_code_generate_type() -> str:
93-
code_generate_type = getattr(settings, "code_generate_type", "number")
93+
code_generate_type = getattr(settings, "code_generate_type", "secret")
9494
if code_generate_type in {"secret", "string"}:
9595
return "secret"
9696
return "number"
@@ -127,5 +127,6 @@ async def calculate_file_hash(file: UploadFile, chunk_size=1024 * 1024) -> str:
127127

128128
ip_limit = {
129129
"error": IPRateLimit(count=settings.errorCount, minutes=settings.errorMinute),
130+
"metadata": IPRateLimit(count=settings.errorCount, minutes=settings.errorMinute),
130131
"upload": IPRateLimit(count=settings.uploadCount, minutes=settings.uploadMinute),
131132
}

apps/base/views.py

Lines changed: 57 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from fastapi import APIRouter, Form, Request, UploadFile, File, Depends, HTTPException
1212
from pydantic import BaseModel, ValidationError
1313
from starlette import status
14+
from tortoise.expressions import Case, F, Q, When
1415

1516
from apps.admin.dependencies import share_required_login
1617
from apps.base.models import FileCodes, UploadChunk, PresignUploadSession
@@ -29,7 +30,12 @@
2930
from core.response import APIResponse
3031
from core.settings import settings
3132
from core.storage import storages, FileStorageInterface
32-
from core.utils import get_select_token, get_now, sanitize_filename
33+
from core.utils import (
34+
get_file_url as get_proxy_file_url,
35+
get_select_token,
36+
get_now,
37+
sanitize_filename,
38+
)
3339

3440
share_api = APIRouter(prefix="/share", tags=["分享"])
3541

@@ -211,11 +217,25 @@ async def get_code_file_by_code(
211217
return True, file_code
212218

213219

214-
async def update_file_usage(file_code: FileCodes) -> None:
215-
file_code.used_count += 1
216-
if file_code.expired_count > 0:
217-
file_code.expired_count -= 1
218-
await file_code.save()
220+
async def consume_file_usage(file_code: FileCodes) -> bool:
221+
"""原子校验分享状态并记录一次实际领取。"""
222+
now = await get_now()
223+
eligible = (
224+
Q(expired_count__gt=0)
225+
| Q(expired_count__lt=0, expired_at__gt=now)
226+
| Q(expired_count__lt=0, expired_at=None)
227+
)
228+
updated = await FileCodes.filter(Q(id=file_code.id) & eligible).update(
229+
expired_count=Case(
230+
When(expired_count__gt=0, then=F("expired_count") - 1),
231+
default=F("expired_count"),
232+
),
233+
used_count=F("used_count") + 1,
234+
)
235+
if not updated:
236+
return False
237+
await file_code.refresh_from_db()
238+
return True
219239

220240

221241
def build_file_metadata(file_code: FileCodes) -> dict:
@@ -242,9 +262,13 @@ async def build_select_detail(
242262
file_code: FileCodes, file_storage: FileStorageInterface
243263
) -> dict:
244264
metadata = build_file_metadata(file_code)
245-
download_url = (
246-
None if file_code.text is not None else await file_storage.get_file_url(file_code)
247-
)
265+
if file_code.text is not None:
266+
download_url = None
267+
elif file_code.expired_count >= 0:
268+
# 有次数限制的文件必须经过下载接口,第三方直链无法阻止重复使用。
269+
download_url = await get_proxy_file_url(file_code.code)
270+
else:
271+
download_url = await file_storage.get_file_url(file_code)
248272
content = file_code.text if file_code.text is not None else None
249273
return {
250274
**metadata,
@@ -255,24 +279,28 @@ async def build_select_detail(
255279

256280

257281
@share_api.get("/metadata/")
258-
async def get_file_metadata(code: str, ip: str = Depends(ip_limit["error"])):
282+
async def get_file_metadata(code: str, ip: str = Depends(ip_limit["metadata"])):
259283
has, file_code = await get_code_file_by_code(code)
260284
if not has:
261-
ip_limit["error"].add_ip(ip)
285+
ip_limit["metadata"].add_ip(ip)
262286
return APIResponse(code=404, detail=file_code)
263287

264288
assert isinstance(file_code, FileCodes)
289+
ip_limit["metadata"].add_ip(ip)
265290
return APIResponse(detail=build_file_metadata(file_code))
266291

267292

268293
@share_api.post("/metadata/")
269-
async def post_file_metadata(data: SelectFileModel, ip: str = Depends(ip_limit["error"])):
294+
async def post_file_metadata(
295+
data: SelectFileModel, ip: str = Depends(ip_limit["metadata"])
296+
):
270297
has, file_code = await get_code_file_by_code(data.code)
271298
if not has:
272-
ip_limit["error"].add_ip(ip)
299+
ip_limit["metadata"].add_ip(ip)
273300
return APIResponse(code=404, detail=file_code)
274301

275302
assert isinstance(file_code, FileCodes)
303+
ip_limit["metadata"].add_ip(ip)
276304
return APIResponse(detail=build_file_metadata(file_code))
277305

278306

@@ -285,7 +313,8 @@ async def get_code_file(code: str, ip: str = Depends(ip_limit["error"])):
285313
return APIResponse(code=404, detail=file_code)
286314

287315
assert isinstance(file_code, FileCodes)
288-
await update_file_usage(file_code)
316+
if not await consume_file_usage(file_code):
317+
return APIResponse(code=404, detail="文件已过期")
289318
return await file_storage.get_file_response(file_code)
290319

291320

@@ -298,8 +327,16 @@ async def select_file(data: SelectFileModel, ip: str = Depends(ip_limit["error"]
298327
return APIResponse(code=404, detail=file_code)
299328

300329
assert isinstance(file_code, FileCodes)
301-
await update_file_usage(file_code)
302-
return APIResponse(detail=await build_select_detail(file_code, file_storage))
330+
detail = await build_select_detail(file_code, file_storage)
331+
download_url = detail.get("download_url")
332+
consumes_on_download = isinstance(download_url, str) and download_url.startswith(
333+
"/share/download?"
334+
)
335+
if not consumes_on_download and not await consume_file_usage(file_code):
336+
return APIResponse(code=404, detail="文件已过期")
337+
if not consumes_on_download:
338+
detail.update(build_file_metadata(file_code))
339+
return APIResponse(detail=detail)
303340

304341

305342
@share_api.get("/download")
@@ -309,10 +346,12 @@ async def download_file(key: str, code: str, ip: str = Depends(ip_limit["error"]
309346
if await get_select_token(normalized_code) != key:
310347
ip_limit["error"].add_ip(ip)
311348
raise HTTPException(status_code=403, detail="下载鉴权失败")
312-
has, file_code = await get_code_file_by_code(normalized_code, False)
349+
has, file_code = await get_code_file_by_code(normalized_code)
313350
if not has:
314-
return APIResponse(code=404, detail="文件不存在")
351+
return APIResponse(code=404, detail=file_code)
315352
assert isinstance(file_code, FileCodes)
353+
if not await consume_file_usage(file_code):
354+
return APIResponse(code=404, detail="文件已过期")
316355
return (
317356
APIResponse(detail=file_code.text)
318357
if file_code.text

core/config.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,8 @@ async def ensure_security_settings() -> None:
6464
def _sync_ip_limits() -> None:
6565
ip_limit["error"].minutes = settings.errorMinute
6666
ip_limit["error"].count = settings.errorCount
67+
ip_limit["metadata"].minutes = settings.errorMinute
68+
ip_limit["metadata"].count = settings.errorCount
6769
ip_limit["upload"].minutes = settings.uploadMinute
6870
ip_limit["upload"].count = settings.uploadCount
6971

core/settings.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@
4444
"uploadSize": 1024 * 1024 * 10,
4545
"allowed_file_types": ["*"],
4646
"expireStyle": ["day", "hour", "minute", "forever", "count"],
47-
"code_generate_type": "number",
47+
"code_generate_type": "secret",
4848
"uploadMinute": 1,
4949
"enableChunk": 0,
5050
"webdav_url": "",

core/tasks.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ async def delete_expire_files():
2828
if not dirs and not files:
2929
os.rmdir(root)
3030
await ip_limit["error"].remove_expired_ip()
31+
await ip_limit["metadata"].remove_expired_ip()
3132
await ip_limit["upload"].remove_expired_ip()
3233
expire_data = await FileCodes.filter(
3334
Q(expired_at__lt=await get_now()) | Q(expired_count=0)

main.py

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,12 @@
3333
from core.version import APP_VERSION
3434

3535

36+
def normalize_public_flag(value) -> int:
37+
if isinstance(value, str):
38+
return int(value.strip().lower() in {"1", "true", "on", "yes"})
39+
return int(bool(value))
40+
41+
3642
def build_public_config() -> dict:
3743
return {
3844
"name": settings.name,
@@ -45,7 +51,7 @@ def build_public_config() -> dict:
4551
"openUpload": settings.openUpload,
4652
"notify_title": settings.notify_title,
4753
"notify_content": settings.notify_content,
48-
"show_admin_address": settings.showAdminAddr,
54+
"show_admin_address": normalize_public_flag(settings.showAdminAddr),
4955
"max_save_seconds": settings.max_save_seconds,
5056
}
5157

@@ -61,7 +67,7 @@ def build_public_meta() -> dict:
6167
"features": {
6268
"chunkUpload": bool(settings.enableChunk),
6369
"guestUpload": bool(settings.openUpload),
64-
"adminAddressVisible": bool(settings.showAdminAddr),
70+
"adminAddressVisible": bool(normalize_public_flag(settings.showAdminAddr)),
6571
"expirationModes": settings.expireStyle,
6672
},
6773
"limits": {
@@ -153,7 +159,9 @@ def parse_setup_options(data: dict) -> dict:
153159
if not expire_styles:
154160
raise ValueError("至少需要选择一种过期方式")
155161

156-
code_generate_type = get_form_value(data, "code_generate_type", "number")
162+
code_generate_type = get_form_value(
163+
data, "code_generate_type", DEFAULT_CONFIG["code_generate_type"]
164+
)
157165
if code_generate_type not in {"number", "secret"}:
158166
raise ValueError("提取码类型不正确")
159167

@@ -222,7 +230,9 @@ def build_setup_page(error: str = "", form: dict | None = None) -> str:
222230
chunk_checked = (
223231
" checked" if normalize_bool_field(form, "enableChunk", False) else ""
224232
)
225-
code_generate_type = get_form_value(form, "code_generate_type", "number")
233+
code_generate_type = get_form_value(
234+
form, "code_generate_type", DEFAULT_CONFIG["code_generate_type"]
235+
)
226236
selected_expire_styles = get_form_list(form, "expireStyle") or list(
227237
DEFAULT_CONFIG["expireStyle"]
228238
)
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import unittest
2+
3+
from core.settings import settings
4+
from main import build_public_config, build_public_meta
5+
6+
7+
class AdminAddressPublicConfigTests(unittest.TestCase):
8+
def setUp(self):
9+
self._original_user_config = dict(settings.user_config)
10+
11+
def tearDown(self):
12+
settings.user_config = self._original_user_config
13+
14+
def test_admin_address_is_exposed_as_strict_binary_flag(self):
15+
cases = ((0, 0), (1, 1), ("0", 0), ("1", 1))
16+
17+
for configured_value, expected_value in cases:
18+
with self.subTest(configured_value=configured_value):
19+
settings.showAdminAddr = configured_value
20+
21+
public_value = build_public_config()["show_admin_address"]
22+
feature_value = build_public_meta()["features"]["adminAddressVisible"]
23+
24+
self.assertIs(type(public_value), int)
25+
self.assertEqual(public_value, expected_value)
26+
self.assertIs(feature_value, bool(expected_value))

0 commit comments

Comments
 (0)