Skip to content

Commit d8ba85f

Browse files
committed
asset downloads
1 parent 07b00e5 commit d8ba85f

File tree

5 files changed

+125
-103
lines changed

5 files changed

+125
-103
lines changed

backend/app/api/frames.py

Lines changed: 58 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
from jose import JWTError, jwt
77
from http import HTTPStatus
88
from tempfile import NamedTemporaryFile
9-
from scp import SCPClient
109

1110
import httpx
1211
from fastapi import Depends, Request, HTTPException
@@ -221,7 +220,16 @@ async def api_frame_get_assets(id: int, db: Session = Depends(get_db), redis: Re
221220

222221

223222
@api_with_auth.get("/frames/{id:int}/asset")
224-
async def api_frame_get_asset(id: int, request: Request, db: Session = Depends(get_db), redis: Redis = Depends(get_redis)):
223+
async def api_frame_get_asset(
224+
id: int,
225+
request: Request,
226+
db: Session = Depends(get_db),
227+
redis: Redis = Depends(get_redis)
228+
):
229+
"""
230+
Download or stream an asset from the remote frame's filesystem using async SSH.
231+
Uses an MD5 of the remote file to cache the content in Redis.
232+
"""
225233
frame = db.get(Frame, id)
226234
if frame is None:
227235
raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Frame not found")
@@ -235,54 +243,85 @@ async def api_frame_get_asset(id: int, request: Request, db: Session = Depends(g
235243
raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail="Path parameter is required")
236244

237245
normalized_path = os.path.normpath(os.path.join(assets_path, path))
246+
# Ensure the requested asset is inside the assets_path directory
238247
if not normalized_path.startswith(os.path.normpath(assets_path)):
239248
raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail="Invalid asset path")
240249

241250
try:
242251
ssh = await get_ssh_connection(db, redis, frame)
243252
try:
253+
# 1) Generate an MD5 sum of the remote file
244254
escaped_path = shlex.quote(normalized_path)
245255
command = f"md5sum {escaped_path}"
246256
await log(db, redis, frame.id, "stdinfo", f"> {command}")
247-
stdin, stdout, stderr = ssh.exec_command(command)
248-
md5sum_output = stdout.read().decode().strip()
257+
258+
# We'll read the MD5 from the command output
259+
md5_output: list[str] = []
260+
await exec_command(db, redis, frame, ssh, command, output=md5_output, log_output=False)
261+
md5sum_output = "".join(md5_output).strip()
249262
if not md5sum_output:
250263
raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Asset not found")
251264

252265
md5sum = md5sum_output.split()[0]
253266
cache_key = f'asset:{md5sum}'
254267

268+
# 2) Check if we already have this asset cached in Redis
255269
cached_asset = await redis.get(cache_key)
256270
if cached_asset:
257271
return StreamingResponse(
258272
io.BytesIO(cached_asset),
259273
media_type='image/png' if mode == 'image' else 'application/octet-stream',
260274
headers={
261-
"Content-Disposition": f'{"attachment" if mode == "download" else "inline"}; filename={filename}'
275+
"Content-Disposition": (
276+
f'{"attachment" if mode == "download" else "inline"}; filename={filename}'
277+
)
262278
}
263279
)
264280

265-
with NamedTemporaryFile(delete=True) as temp_file:
266-
with SCPClient(ssh.get_transport()) as scp:
267-
scp.get(normalized_path, temp_file.name)
268-
temp_file.seek(0)
269-
asset_content = temp_file.read()
270-
await redis.set(cache_key, asset_content, ex=86400 * 30)
271-
return StreamingResponse(
272-
io.BytesIO(asset_content),
273-
media_type='image/png' if mode == 'image' else 'application/octet-stream',
274-
headers={
275-
"Content-Disposition": f'{"attachment" if mode == "download" else "inline"}; filename={filename}'
276-
}
277-
)
281+
# 3) No cache found. Use asyncssh.scp to copy the remote file into a local temp file.
282+
with NamedTemporaryFile(delete=False) as temp_file:
283+
local_temp_path = temp_file.name
284+
285+
# scp from remote -> local
286+
# Note: (ssh, normalized_path) means "download from 'normalized_path' on the remote `ssh` connection"
287+
import asyncssh
288+
await asyncssh.scp(
289+
(ssh, escaped_path),
290+
local_temp_path,
291+
recurse=False
292+
)
293+
294+
# 4) Read file contents and store in Redis
295+
with open(local_temp_path, "rb") as f:
296+
asset_content = f.read()
297+
298+
await redis.set(cache_key, asset_content, ex=86400 * 30)
299+
300+
# Cleanup temp file
301+
os.remove(local_temp_path)
302+
303+
# 5) Return the file to the user
304+
return StreamingResponse(
305+
io.BytesIO(asset_content),
306+
media_type='image/png' if mode == 'image' else 'application/octet-stream',
307+
headers={
308+
"Content-Disposition": (
309+
f'{"attachment" if mode == "download" else "inline"}; filename={filename}'
310+
)
311+
}
312+
)
313+
except Exception as e:
314+
print(e)
315+
raise e
316+
278317
finally:
279318
await remove_ssh_connection(ssh)
319+
280320
except HTTPException:
281321
raise
282322
except Exception as e:
283323
raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(e))
284324

285-
286325
@api_with_auth.post("/frames/{id:int}/reset")
287326
async def api_frame_reset_event(id: int, redis: Redis = Depends(get_redis)):
288327
try:

backend/requirements.in

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,12 @@
11
alembic
22
arq
33
asyncssh
4-
dacite
54
email_validator
65
fastapi[standard]
76
honcho
87
imagehash
9-
ipdb
108
jwt
119
mypy
12-
paramiko
13-
passlib
1410
pillow==9.5.0
1511
pip-tools
1612
pre-commit
@@ -20,11 +16,9 @@ python-jose
2016
redis
2117
requests
2218
ruff
23-
scp
2419
sentry-sdk[flask]
2520
sqlalchemy
2621
sqlmodel
27-
types-paramiko
2822
types-Pillow
2923
types-redis
3024
types-requests

backend/requirements.txt

Lines changed: 3 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -8,16 +8,8 @@ anyio==4.7.0
88
# httpx
99
# starlette
1010
# watchfiles
11-
appnope==0.1.4
12-
# via ipython
1311
arq==0.26.1
14-
asttokens==2.4.0
15-
# via stack-data
1612
asyncssh==2.19.0
17-
backcall==0.2.0
18-
# via ipython
19-
bcrypt==4.0.1
20-
# via paramiko
2113
blinker==1.6.2
2214
# via
2315
# flask
@@ -31,9 +23,7 @@ certifi==2023.7.22
3123
# requests
3224
# sentry-sdk
3325
cffi==1.15.1
34-
# via
35-
# cryptography
36-
# pynacl
26+
# via cryptography
3727
cfgv==3.4.0
3828
# via pre-commit
3929
charset-normalizer==3.2.0
@@ -50,15 +40,8 @@ cryptography==41.0.3
5040
# via
5141
# asyncssh
5242
# jwt
53-
# paramiko
54-
# types-paramiko
5543
# types-pyopenssl
5644
# types-redis
57-
dacite==1.8.1
58-
decorator==5.1.1
59-
# via
60-
# ipdb
61-
# ipython
6245
distlib==0.3.8
6346
# via virtualenv
6447
dnspython==2.4.2
@@ -67,8 +50,6 @@ ecdsa==0.19.0
6750
# via python-jose
6851
email-validator==2.0.0.post2
6952
# via fastapi
70-
executing==1.2.0
71-
# via stack-data
7253
fastapi==0.115.6
7354
fastapi-cli==0.0.6
7455
# via fastapi
@@ -100,13 +81,8 @@ idna==3.4
10081
imagehash==4.3.1
10182
iniconfig==2.0.0
10283
# via pytest
103-
ipdb==0.13.13
104-
ipython==8.15.0
105-
# via ipdb
10684
itsdangerous==2.1.2
10785
# via flask
108-
jedi==0.19.0
109-
# via ipython
11086
jinja2==3.1.2
11187
# via
11288
# fastapi
@@ -122,8 +98,6 @@ markupsafe==2.1.3
12298
# mako
12399
# sentry-sdk
124100
# werkzeug
125-
matplotlib-inline==0.1.6
126-
# via ipython
127101
mdurl==0.1.2
128102
# via markdown-it-py
129103
mypy==1.13.0
@@ -140,15 +114,6 @@ packaging==23.2
140114
# via
141115
# build
142116
# pytest
143-
paramiko==3.3.1
144-
# via scp
145-
parso==0.8.3
146-
# via jedi
147-
passlib==1.7.4
148-
pexpect==4.8.0
149-
# via ipython
150-
pickleshare==0.7.5
151-
# via ipython
152117
pillow==9.5.0
153118
# via imagehash
154119
pip==24.3.1
@@ -159,12 +124,6 @@ platformdirs==4.1.0
159124
pluggy==1.3.0
160125
# via pytest
161126
pre-commit==3.6.0
162-
prompt-toolkit==3.0.39
163-
# via ipython
164-
ptyprocess==0.7.0
165-
# via pexpect
166-
pure-eval==0.2.2
167-
# via stack-data
168127
pyasn1==0.6.1
169128
# via
170129
# python-jose
@@ -178,11 +137,7 @@ pydantic==2.10.3
178137
pydantic-core==2.27.1
179138
# via pydantic
180139
pygments==2.16.1
181-
# via
182-
# ipython
183-
# rich
184-
pynacl==1.5.0
185-
# via paramiko
140+
# via rich
186141
pyproject-hooks==1.0.0
187142
# via build
188143
pytest==7.4.3
@@ -213,7 +168,6 @@ rsa==4.9
213168
ruff==0.1.14
214169
scipy==1.12.0
215170
# via imagehash
216-
scp==0.14.5
217171
sentry-sdk==1.35.0
218172
setuptools==75.6.0
219173
# via
@@ -222,29 +176,20 @@ setuptools==75.6.0
222176
shellingham==1.5.4
223177
# via typer
224178
six==1.16.0
225-
# via
226-
# asttokens
227-
# ecdsa
179+
# via ecdsa
228180
sniffio==1.3.1
229181
# via anyio
230182
sqlalchemy==2.0.19
231183
# via
232184
# alembic
233185
# sqlmodel
234186
sqlmodel==0.0.22
235-
stack-data==0.6.2
236-
# via ipython
237187
starlette==0.41.3
238188
# via fastapi
239-
traitlets==5.10.0
240-
# via
241-
# ipython
242-
# matplotlib-inline
243189
typer==0.15.1
244190
# via fastapi-cli
245191
types-cffi==1.16.0.20240331
246192
# via types-pyopenssl
247-
types-paramiko==3.5.0.20240928
248193
types-pillow==10.2.0.20240822
249194
types-pyopenssl==24.1.0.20240722
250195
# via types-redis
@@ -279,8 +224,6 @@ virtualenv==20.25.0
279224
# via pre-commit
280225
watchfiles==1.0.3
281226
# via uvicorn
282-
wcwidth==0.2.6
283-
# via prompt-toolkit
284227
websockets==14.1
285228
# via uvicorn
286229
werkzeug==2.3.6

frontend/src/scenes/frame/panels/Assets/Asset.tsx

Lines changed: 42 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,60 @@
11
import { useValues } from 'kea'
22
import { frameLogic } from '../../frameLogic'
3-
import { useState } from 'react'
3+
import { useEffect, useState } from 'react'
4+
import { apiFetch } from '../../../../utils/apiFetch'
5+
import { Button } from '../../../../components/Button'
46

57
interface AssetProps {
68
path: string
79
}
810

911
export function Asset({ path }: AssetProps) {
1012
const { frame } = useValues(frameLogic)
13+
1114
const isImage = path.endsWith('.png') || path.endsWith('.jpg') || path.endsWith('.jpeg') || path.endsWith('.gif')
1215
const [isLoading, setIsLoading] = useState(true)
16+
const [asset, setAsset] = useState<string | null>(null)
17+
18+
useEffect(() => {
19+
async function fetchAsset() {
20+
setIsLoading(true)
21+
setAsset(null)
22+
const resource = await apiFetch(`/api/frames/${frame.id}/asset?path=${encodeURIComponent(path)}`)
23+
const blob = await resource.blob()
24+
setAsset(URL.createObjectURL(blob))
25+
setIsLoading(false)
26+
}
27+
fetchAsset()
28+
}, [path])
1329

1430
return (
1531
<div className="w-full">
16-
{isImage ? (
17-
<>
18-
<img
19-
onLoad={() => setIsLoading(false)}
20-
onError={() => setIsLoading(false)}
21-
className="max-w-full"
22-
src={`/api/frames/${frame.id}/asset?path=${encodeURIComponent(path)}`}
23-
alt={path}
24-
/>
25-
{isLoading ? <div>Loading...</div> : null}
26-
</>
32+
{isLoading ? (
33+
<div>Loading...</div>
34+
) : !asset ? (
35+
<div>Error loading asset</div>
36+
) : isImage ? (
37+
<img
38+
onLoad={() => setIsLoading(false)}
39+
onError={() => setIsLoading(false)}
40+
className="max-w-full"
41+
src={asset}
42+
alt={path}
43+
/>
2744
) : (
28-
<>{path}</>
45+
<div className="space-y-2">
46+
<div>{path}</div>
47+
<Button
48+
onClick={() => {
49+
const a = document.createElement('a')
50+
a.href = asset
51+
a.download = path
52+
a.click()
53+
}}
54+
>
55+
Download
56+
</Button>
57+
</div>
2958
)}
3059
</div>
3160
)

0 commit comments

Comments
 (0)