Skip to content

Commit 6fa6d0c

Browse files
committed
Multiple download protection
1 parent e4f2bac commit 6fa6d0c

File tree

6 files changed

+68
-2
lines changed

6 files changed

+68
-2
lines changed

lib/store.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ def __init__(self, transfer_id: str):
2222
self._k_queue = self.key('queue')
2323
self._k_meta = self.key('metadata')
2424
self._k_cleanup = f'cleanup:{transfer_id}'
25+
self._k_receiver_connected = self.key('receiver_connected')
2526

2627
@classmethod
2728
def get_redis(cls) -> redis.Redis:
@@ -121,6 +122,17 @@ async def get_metadata(self) -> str | None:
121122

122123
## Transfer state operations ##
123124

125+
async def set_receiver_connected(self) -> bool:
126+
"""
127+
Mark that a receiver has connected for this transfer.
128+
Returns True if the flag was set, False if it was already created.
129+
"""
130+
return bool(await self.redis.set(self._k_receiver_connected, '1', ex=300, nx=True))
131+
132+
async def is_receiver_connected(self) -> bool:
133+
"""Check if a receiver has already connected."""
134+
return await self.redis.exists(self._k_receiver_connected) > 0
135+
124136
async def set_completed(self) -> None:
125137
"""Mark the transfer as completed."""
126138
await self.redis.set(f'completed:{self.transfer_id}', '1', ex=300, nx=True)

lib/transfer.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,12 @@ async def wait_for_client_connected(self):
5858
await self.wait_for_event('client_connected')
5959
self.debug(f"△ Received client connected notification.")
6060

61+
async def is_receiver_connected(self) -> bool:
62+
return await self.store.is_receiver_connected()
63+
64+
async def set_receiver_connected(self) -> bool:
65+
return await self.store.set_receiver_connected()
66+
6167
async def is_interrupted(self) -> bool:
6268
return await self.store.is_interrupted()
6369

static/download.html

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,5 +38,12 @@ <h2>Ready to download</h2>
3838
<strong>©</strong> <a href="https://github.com/codeSamuraii/">Rémi Héneault</a></p>
3939
</footer>
4040
</div>
41+
<script>
42+
document.getElementById('download-button').addEventListener('click', function() {{
43+
this.classList.add('disabled');
44+
this.textContent = 'Downloading...';
45+
this.style.pointerEvents = 'none';
46+
}});
47+
</script>
4148
</body>
4249
</html>

tests/test_endpoints.py

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ def mock_redis():
3434
"""Mock Redis client for testing."""
3535
with patch('lib.store.Store.get_redis') as mock:
3636
redis_mock = AsyncMock()
37+
# Default for receiver connected check
38+
redis_mock.exists.return_value = 0
3739
mock.return_value = redis_mock
3840
yield redis_mock
3941

@@ -146,7 +148,9 @@ async def test_http_download_success(self, test_client, test_file, mock_redis):
146148

147149
# Mock Redis operations for metadata retrieval
148150
mock_redis.get.return_value = file_metadata.to_json()
149-
mock_redis.set.return_value = None
151+
# Mock receiver not connected, then successfully set
152+
mock_redis.exists.return_value = 0
153+
mock_redis.set.return_value = True
150154
mock_redis.publish.return_value = None
151155
mock_redis.scan.return_value = (0, [])
152156
mock_redis.delete.return_value = 1
@@ -197,6 +201,7 @@ async def test_http_download_prefetch_protection(self, test_client, mock_redis):
197201
)
198202

199203
mock_redis.get.return_value = file_metadata.to_json()
204+
mock_redis.exists.return_value = 0 # Receiver not connected
200205

201206
# Simulate WhatsApp prefetch request
202207
headers = {"user-agent": "WhatsApp/2.21.1"}
@@ -207,6 +212,24 @@ async def test_http_download_prefetch_protection(self, test_client, mock_redis):
207212
if response.status_code == 200:
208213
assert "text/html" in response.headers.get("content-type", "") or "test.txt" in response.text
209214

215+
@pytest.mark.asyncio
216+
async def test_http_download_already_connected(self, test_client, mock_redis):
217+
"""Test that a second download attempt is rejected."""
218+
uid = "test-already-connected"
219+
file_metadata = FileMetadata(
220+
name="test.txt",
221+
size=1000,
222+
content_type="text/plain"
223+
)
224+
225+
mock_redis.get.return_value = file_metadata.to_json()
226+
mock_redis.exists.return_value = 1 # Simulate receiver already connected
227+
228+
response = test_client.get(f"/{uid}?download=true")
229+
230+
assert response.status_code == 409
231+
assert "A client is already downloading this file" in response.text
232+
210233

211234
class TestHTTPUpload:
212235
"""Test HTTP upload endpoint."""
@@ -320,7 +343,7 @@ async def mock_brpop(keys, timeout=None):
320343
content_type="text/plain"
321344
).to_json()
322345
mock_redis.llen.return_value = 0
323-
mock_redis.exists.return_value = False
346+
mock_redis.exists.return_value = 0
324347
mock_redis.publish.return_value = None
325348
mock_redis.scan.return_value = (0, [])
326349
mock_redis.delete.return_value = 0
@@ -351,6 +374,8 @@ def upload_file():
351374
# Step 2: Download via HTTP
352375
def download_file():
353376
client = TestClient(app)
377+
# Mock set_receiver_connected to succeed
378+
mock_redis.set.return_value = True
354379
response = client.get(f"/{uid}?download=true")
355380
assert response.status_code == 200
356381
return response.content

views/http.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,9 @@ async def http_download(request: Request, uid: str):
115115
else:
116116
log.info(f"▼ HTTP download request for: {transfer.file}")
117117

118+
if await transfer.is_receiver_connected():
119+
raise HTTPException(status_code=409, detail="A client is already downloading this file.")
120+
118121
file_name, file_size, file_type = transfer.get_file_info()
119122
user_agent = request.headers.get('user-agent', '').lower()
120123
is_prefetcher = any(prefetch_ua in user_agent for prefetch_ua in PREFETCHER_USER_AGENTS)
@@ -135,6 +138,9 @@ async def http_download(request: Request, uid: str):
135138
)
136139
return HTMLResponse(content=html_download, status_code=200)
137140

141+
if not await transfer.set_receiver_connected():
142+
raise HTTPException(status_code=409, detail="A client is already downloading this file.")
143+
138144
await transfer.set_client_connected()
139145

140146
transfer.info("▼ Starting download...")

views/websockets.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,11 @@ async def websocket_download(background_tasks: BackgroundTasks, websocket: WebSo
7878
await websocket.send_text("File not found")
7979
return
8080

81+
if await transfer.is_receiver_connected():
82+
log.warning("▼ A client is already downloading this file.")
83+
await websocket.send_text("Error: A client is already downloading this file.")
84+
return
85+
8186
file_name, file_size, file_type = transfer.get_file_info()
8287
transfer.debug(f"▼ File: name={file_name}, size={file_size}, type={file_type}")
8388
await websocket.send_json({'file_name': file_name, 'file_size': file_size, 'file_type': file_type})
@@ -93,6 +98,11 @@ async def websocket_download(background_tasks: BackgroundTasks, websocket: WebSo
9398
transfer.warning("▼ Client disconnected while waiting for go-ahead")
9499
return
95100

101+
if not await transfer.set_receiver_connected():
102+
log.warning("▼ A client is already downloading this file.")
103+
await websocket.send_text("Error: A client is already downloading this file.")
104+
return
105+
96106
transfer.info("▼ Notifying client is connected.")
97107
await transfer.set_client_connected()
98108
background_tasks.add_task(transfer.finalize_download)

0 commit comments

Comments
 (0)