Skip to content

Commit f620e29

Browse files
committed
Fix download on 3.x (presigned URL)
1 parent dec5a00 commit f620e29

File tree

13 files changed

+243
-31
lines changed

13 files changed

+243
-31
lines changed

airflow_code_editor/api/api.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,13 @@
2626
from airflow_code_editor.commons import (
2727
HTTP_200_OK,
2828
HTTP_400_BAD_REQUEST,
29+
HTTP_401_UNAUTHORIZED,
2930
HTTP_404_NOT_FOUND,
3031
HTTP_500_SERVER_ERROR,
3132
VERSION,
3233
)
3334
from airflow_code_editor.fs import RootFS
35+
from airflow_code_editor.presigned import create_presigned, decode_presigned
3436
from airflow_code_editor.tree import get_stat, get_tree
3537
from airflow_code_editor.utils import (
3638
DummyLexer,
@@ -53,6 +55,8 @@
5355
"search",
5456
"ping",
5557
"get_version",
58+
"generate_presigned",
59+
"load_presigned",
5660
]
5761

5862

@@ -250,3 +254,23 @@ def get_version():
250254
"version": VERSION,
251255
"airflow_version": airflow_version,
252256
}
257+
258+
259+
def generate_presigned(path):
260+
"Generate a presigned URL token for downloading a file/git object"
261+
return {
262+
"token": create_presigned(path),
263+
}
264+
265+
266+
def load_presigned(token: str):
267+
"Download a file/git object using a presigned URL"
268+
try:
269+
path = decode_presigned(token)
270+
except Exception as ex:
271+
logging.error(ex)
272+
return prepare_api_response(
273+
error_message="Not authenticated",
274+
http_status_code=HTTP_401_UNAUTHORIZED,
275+
)
276+
return load(path)

airflow_code_editor/api/code_editor.yaml

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
openapi: "3.0.3"
22
info:
33
title: "Airflow Code Editor API"
4-
version: "1.0.0"
4+
version: "8.1.0"
55
description: |
66
# Overview
77
@@ -218,6 +218,56 @@ paths:
218218
description: "Success"
219219
$ref: '#/components/responses/VersionInfo'
220220

221+
/generate_presigned:
222+
post:
223+
summary: "Generate a presigned URL token for downloading a file/git object"
224+
x-openapi-router-controller: airflow_code_editor.api.flask_endpoints
225+
operationId: generate_presigned
226+
tags: [Presigned]
227+
requestBody:
228+
required: true
229+
content:
230+
application/json:
231+
schema:
232+
type: object
233+
properties:
234+
path:
235+
type: string
236+
description: "File/git object path"
237+
responses:
238+
'200':
239+
description: "Success"
240+
$ref: '#/components/responses/GeneratePresignedResponse'
241+
'401':
242+
description: "Not authenticated"
243+
'403':
244+
description: "Client does not have sufficient permission"
245+
246+
/presigned/{token}:
247+
get:
248+
summary: "Download a file/git object using a presigned URL"
249+
x-openapi-router-controller: airflow_code_editor.api.flask_endpoints
250+
operationId: load_presigned
251+
tags: [Presigned]
252+
parameters:
253+
- name: token
254+
in: path
255+
description: "Presigned URL token"
256+
required: true
257+
allowReserved: true
258+
schema:
259+
type: string
260+
format: path
261+
responses:
262+
'200':
263+
description: "Success"
264+
'403':
265+
description: "Client does not have sufficient permission"
266+
'401':
267+
description: "Not authenticated"
268+
'404':
269+
description: "File not found"
270+
221271
components:
222272
schemas:
223273
TreeEntity:
@@ -328,6 +378,19 @@ components:
328378
- version
329379
- airflow_version
330380

381+
GeneratePresignedResponse:
382+
description: Generate presigned URL token response
383+
content:
384+
application/json:
385+
schema:
386+
type: object
387+
properties:
388+
token:
389+
type: string
390+
description: Presigned URL token
391+
required:
392+
- token
393+
331394
parameters:
332395
All:
333396
in: query

airflow_code_editor/api/fastapi_endpoints.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,28 @@ def delete(path: str, request: Request):
7171
return api.delete(path)
7272

7373

74+
@app.post(
75+
"/generate_presigned",
76+
dependencies=[Depends(requires_access_dag(method="GET"))],
77+
include_in_schema=False,
78+
)
79+
async def generate_presigned(request: Request):
80+
"Generate a presigned URL token for downloading a file/git object"
81+
body = await request.json()
82+
path = body.get("path", "")
83+
return api.generate_presigned(path)
84+
85+
86+
@app.get(
87+
"/presigned/{token}",
88+
dependencies=[], # presigned URL does not require authentication
89+
include_in_schema=False,
90+
)
91+
def presigned(token: str, request: Request):
92+
"Download a file/git object using a presigned URL"
93+
return api.load_presigned(token)
94+
95+
7496
@app.post(
7597
"/format",
7698
dependencies=[Depends(requires_access_dag(method="GET"))],

airflow_code_editor/api/flask_endpoints.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@
3535
"search",
3636
"post_git",
3737
"get_version",
38+
"generate_presigned",
39+
"load_presigned",
3840
"load_specification",
3941
"api_blueprint",
4042
]
@@ -99,6 +101,21 @@ def get_version():
99101
return api.get_version()
100102

101103

104+
@security.requires_access_dag("PUT")
105+
@csrf.exempt
106+
def generate_presigned(*, path: str = None):
107+
"Generate a presigned URL token for downloading a file/git object"
108+
path = request.json.get("path", "")
109+
return api.generate_presigned(path)
110+
111+
112+
# security is not required for presigned URLs
113+
@csrf.exempt
114+
def load_presigned(*, token: str = None):
115+
"Download a file/git object using a presigned URL"
116+
return api.load_presigned(token)
117+
118+
102119
def load_specification() -> dict:
103120
with importlib.resources.path("airflow_code_editor.api", "code_editor.yaml") as f:
104121
return safe_load(f.read_text())

airflow_code_editor/app_builder_view.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,18 @@ def get_version(self):
124124
def ping(self):
125125
return api.ping()
126126

127+
@expose("/generate_presigned", methods=["POST"])
128+
@auth.has_access(PERMISSIONS)
129+
def generate_presigned(self):
130+
path = request.json.get("path", "")
131+
return api.generate_presigned(path)
132+
133+
@expose("/presigned/<path:path>", methods=["GET"])
134+
# auth is not required for presigned URLs
135+
def load_presigned(self, path=None):
136+
"Download a file/git object using a presigned URL"
137+
return api.load_presigned(path)
138+
127139

128140
appbuilder_code_editor_view = AppBuilderCodeEditorView()
129141
appbuilder_view = {

airflow_code_editor/commons.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
'SUPPORTED_GIT_COMMANDS',
3333
'HTTP_200_OK',
3434
'HTTP_400_BAD_REQUEST',
35+
'HTTP_401_UNAUTHORIZED',
3536
'HTTP_404_NOT_FOUND',
3637
'HTTP_500_SERVER_ERROR',
3738
'PLUGIN_DEFAULT_CONFIG',
@@ -64,6 +65,7 @@
6465
DEFAULT_GIT_BRANCH = 'main'
6566
HTTP_200_OK = 200
6667
HTTP_400_BAD_REQUEST = 400
68+
HTTP_401_UNAUTHORIZED = 401
6769
HTTP_404_NOT_FOUND = 404
6870
HTTP_500_SERVER_ERROR = 500
6971
SUPPORTED_GIT_COMMANDS = [

airflow_code_editor/fastapi_app.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
from fastapi.templating import Jinja2Templates
2525

2626
from airflow_code_editor.api.fastapi_endpoints import app
27-
from airflow_code_editor.commons import PLUGIN_LONG_NAME, PLUGIN_NAME, MENU_LABEL
27+
from airflow_code_editor.commons import MENU_LABEL, PLUGIN_LONG_NAME, PLUGIN_NAME
2828
from airflow_code_editor.utils import is_enabled
2929

3030
__all__ = ["fastapi_app"]

airflow_code_editor/presigned.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
#!/usr/bin/env python
2+
#
3+
# Copyright 2019 Andrea Bonomi <[email protected]>
4+
#
5+
# Licensed under the Apache License, Version 2.0 (the "License");
6+
# you may not use this file except in compliance with the License.
7+
# You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing, software
12+
# distributed under the License is distributed on an "AS IS" BASIS,
13+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
# See the License for the specific language governing permissions and
15+
# limitations under the License
16+
#
17+
18+
import time
19+
20+
from airflow.configuration import conf
21+
from itsdangerous import URLSafeSerializer
22+
23+
__all__ = ["create_presigned", "decode_presigned"]
24+
25+
26+
def get_signer() -> URLSafeSerializer:
27+
"""
28+
Get a signer instance
29+
"""
30+
secret_key = conf.get_mandatory_value("core", "fernet_key")
31+
return URLSafeSerializer(secret_key)
32+
33+
34+
def create_presigned(filename: str, expires_in: int = 300) -> str:
35+
"""
36+
Generate a signed token with expiry
37+
"""
38+
payload = {"filename": filename, "exp": int(time.time()) + expires_in}
39+
return get_signer().dumps(payload)
40+
41+
42+
def decode_presigned(token: str) -> str:
43+
"""
44+
Decode and verify a signed token
45+
"""
46+
data = get_signer().loads(token)
47+
if data["exp"] < int(time.time()):
48+
raise ValueError("Token has expired")
49+
return data["filename"]

0 commit comments

Comments
 (0)