Skip to content

Commit fccc491

Browse files
committed
✨(interop) allow to save an attachment into Drive workspace
Setup Drive resource server query to retrieve and save files from a configured Drive instance through OIDC resource server. suitenumerique/drive#379
1 parent 3e2a502 commit fccc491

File tree

31 files changed

+1833
-51
lines changed

31 files changed

+1833
-51
lines changed

docs/env.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,9 @@ The application uses a new environment file structure with `.defaults` and `.loc
156156
| `OIDC_OP_LOGOUT_ENDPOINT` | None | OIDC logout endpoint | Optional |
157157
| `OIDC_USERINFO_ESSENTIAL_CLAIMS` | `[]` | Essential OIDC claims | Optional |
158158
| `OIDC_USERINFO_FULLNAME_FIELDS` | `["first_name", "last_name"]` | Fields to use for full name | Optional |
159+
| `OIDC_STORE_ACCESS_TOKEN` | `False` | Store access token | Optional |
160+
| `OIDC_STORE_REFRESH_TOKEN` | `False` | Store refresh token | Optional |
161+
| `OIDC_STORE_REFRESH_TOKEN_KEY` | `None` | Refresh token encryption key (Must be a valid Fernet key) | Optional |
159162

160163

161164
### OIDC Advanced Settings

src/backend/core/api/openapi.json

Lines changed: 148 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -182,12 +182,17 @@
182182
"api_url": {
183183
"type": "string",
184184
"readOnly": true
185+
},
186+
"file_url": {
187+
"type": "string",
188+
"readOnly": true
185189
}
186190
},
187191
"readOnly": true,
188192
"required": [
189193
"sdk_url",
190-
"api_url"
194+
"api_url",
195+
"file_url"
191196
]
192197
},
193198
"SCHEMA_CUSTOM_ATTRIBUTES_USER": {
@@ -1652,6 +1657,82 @@
16521657
}
16531658
}
16541659
},
1660+
"/api/v1.0/interop/drive/": {
1661+
"get": {
1662+
"operationId": "interop_drive_retrieve",
1663+
"description": "Search for files created by the current user.",
1664+
"parameters": [
1665+
{
1666+
"in": "query",
1667+
"name": "title",
1668+
"schema": {
1669+
"type": "string"
1670+
},
1671+
"description": "Search files by title.",
1672+
"required": true
1673+
}
1674+
],
1675+
"tags": [
1676+
"interop/drive"
1677+
],
1678+
"security": [
1679+
{
1680+
"cookieAuth": []
1681+
}
1682+
],
1683+
"responses": {
1684+
"200": {
1685+
"content": {
1686+
"application/json": {
1687+
"schema": {
1688+
"$ref": "#/components/schemas/PaginatedDriveItemResponse"
1689+
}
1690+
}
1691+
},
1692+
"description": "Files found"
1693+
}
1694+
}
1695+
},
1696+
"post": {
1697+
"operationId": "interop_drive_create",
1698+
"description": "Create a new file in the main workspace.",
1699+
"tags": [
1700+
"interop"
1701+
],
1702+
"requestBody": {
1703+
"content": {
1704+
"application/json": {
1705+
"schema": {
1706+
"$ref": "#/components/schemas/DriveUploadAttachmentRequest"
1707+
}
1708+
},
1709+
"multipart/form-data": {
1710+
"schema": {
1711+
"$ref": "#/components/schemas/DriveUploadAttachmentRequest"
1712+
}
1713+
}
1714+
},
1715+
"required": true
1716+
},
1717+
"security": [
1718+
{
1719+
"cookieAuth": []
1720+
}
1721+
],
1722+
"responses": {
1723+
"200": {
1724+
"content": {
1725+
"application/json": {
1726+
"schema": {
1727+
"$ref": "#/components/schemas/PartialDriveItem"
1728+
}
1729+
}
1730+
},
1731+
"description": "File created successfully"
1732+
}
1733+
}
1734+
}
1735+
},
16551736
"/api/v1.0/labels/": {
16561737
"get": {
16571738
"operationId": "labels_list",
@@ -4972,12 +5053,6 @@
49725053
"type": "object",
49735054
"description": "Serialize attachments.",
49745055
"properties": {
4975-
"id": {
4976-
"type": "string",
4977-
"format": "uuid",
4978-
"readOnly": true,
4979-
"description": "primary key for the record as UUID"
4980-
},
49815056
"blobId": {
49825057
"type": "string",
49835058
"format": "uuid",
@@ -5020,7 +5095,6 @@
50205095
"blobId",
50215096
"cid",
50225097
"created_at",
5023-
"id",
50245098
"name",
50255099
"sha256",
50265100
"size",
@@ -5225,6 +5299,19 @@
52255299
"senderId"
52265300
]
52275301
},
5302+
"DriveUploadAttachmentRequest": {
5303+
"type": "object",
5304+
"properties": {
5305+
"blob_id": {
5306+
"type": "string",
5307+
"format": "uuid",
5308+
"description": "ID of the attachment to upload"
5309+
}
5310+
},
5311+
"required": [
5312+
"blob_id"
5313+
]
5314+
},
52285315
"FlagEnum": {
52295316
"enum": [
52305317
"unread",
@@ -6510,6 +6597,34 @@
65106597
"signature"
65116598
]
65126599
},
6600+
"PaginatedDriveItemResponse": {
6601+
"type": "object",
6602+
"properties": {
6603+
"count": {
6604+
"type": "integer"
6605+
},
6606+
"next": {
6607+
"type": "string",
6608+
"nullable": true
6609+
},
6610+
"previous": {
6611+
"type": "string",
6612+
"nullable": true
6613+
},
6614+
"results": {
6615+
"type": "array",
6616+
"items": {
6617+
"$ref": "#/components/schemas/PartialDriveItem"
6618+
}
6619+
}
6620+
},
6621+
"required": [
6622+
"count",
6623+
"next",
6624+
"previous",
6625+
"results"
6626+
]
6627+
},
65136628
"PaginatedMailDomainAdminList": {
65146629
"type": "object",
65156630
"required": [
@@ -6696,6 +6811,31 @@
66966811
}
66976812
}
66986813
},
6814+
"PartialDriveItem": {
6815+
"type": "object",
6816+
"description": "Serializer for Drive Item resource (OpenAPI purpose only...).\nIt supports partially the Drive Item resource response structure.\nWe declare only fields that are useful in the Messages context.",
6817+
"properties": {
6818+
"id": {
6819+
"type": "string",
6820+
"format": "uuid"
6821+
},
6822+
"filename": {
6823+
"type": "string"
6824+
},
6825+
"mimetype": {
6826+
"type": "string"
6827+
},
6828+
"size": {
6829+
"type": "integer"
6830+
}
6831+
},
6832+
"required": [
6833+
"filename",
6834+
"id",
6835+
"mimetype",
6836+
"size"
6837+
]
6838+
},
66996839
"PatchedLabelRequest": {
67006840
"type": "object",
67016841
"description": "Serializer for Label model.",

src/backend/core/api/serializers.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -388,7 +388,6 @@ def get_sha256(self, obj):
388388
class Meta:
389389
model = models.Attachment
390390
fields = [
391-
"id",
392391
"blobId",
393392
"name",
394393
"size",
@@ -1470,3 +1469,26 @@ def create(self, validated_data):
14701469

14711470
def update(self, instance, validated_data):
14721471
"""This serializer is only used to validate the data, not to create or update."""
1472+
1473+
1474+
class PartialDriveItemSerializer(serializers.Serializer):
1475+
"""
1476+
Serializer for Drive Item resource (OpenAPI purpose only...).
1477+
It supports partially the Drive Item resource response structure.
1478+
We declare only fields that are useful in the Messages context.
1479+
"""
1480+
1481+
id = serializers.UUIDField(required=True)
1482+
filename = serializers.CharField(required=True)
1483+
mimetype = serializers.CharField(required=True)
1484+
size = serializers.IntegerField(required=True)
1485+
1486+
class Meta:
1487+
fields = ["id", "filename", "mimetype", "size"]
1488+
read_only_fields = fields
1489+
1490+
def create(self, validated_data):
1491+
"""This serializer is only used to validate the data, not to create or update."""
1492+
1493+
def update(self, instance, validated_data):
1494+
"""This serializer is only used to validate the data, not to create or update."""

src/backend/core/api/utils.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
import boto3
88
import botocore
99

10+
from core import models
11+
1012

1113
def flat_to_nested(items):
1214
"""
@@ -68,6 +70,39 @@ def generate_presigned_url(storage, *args, **kwargs):
6870
return s3_client.generate_presigned_url(*args, **kwargs)
6971

7072

73+
def get_attachment_from_blob_id(blob_id, user):
74+
"""
75+
Parse a given blob ID to get the attachment data from the related message raw mime.
76+
Blob IDs in the form msg_[message_id]_[attachment_number] are looked up
77+
directly in the message's attachments.
78+
"""
79+
if not blob_id.startswith("msg_"):
80+
raise ValueError("Invalid blob ID")
81+
82+
message_id = blob_id.split("_")[1]
83+
attachment_number = blob_id.split("_")[2]
84+
message = models.Message.objects.get(id=message_id)
85+
86+
# Does the user have access to the message via its thread?
87+
if not models.ThreadAccess.objects.filter(
88+
thread=message.thread, mailbox__accesses__user=user
89+
).exists():
90+
raise models.Blob.DoesNotExist()
91+
92+
# Does the message have any attachments?
93+
if not message.has_attachments:
94+
raise models.Blob.DoesNotExist()
95+
96+
# Parse the raw mime message to get the attachment
97+
parsed_email = message.get_parsed_data()
98+
attachment = parsed_email.get("attachments", [])[int(attachment_number)]
99+
100+
if not attachment:
101+
raise models.Blob.DoesNotExist()
102+
103+
return attachment
104+
105+
71106
# def generate_s3_authorization_headers(key):
72107
# """
73108
# Generate authorization headers for an s3 object.

src/backend/core/api/viewsets/blob.py

Lines changed: 11 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
from rest_framework.viewsets import ViewSet
1616

1717
from core import models
18-
from core.api import permissions
18+
from core.api import permissions, utils
1919

2020
# Define logger
2121
logger = logging.getLogger(__name__)
@@ -164,23 +164,16 @@ def download(self, request, pk=None):
164164
# Blob IDs in the form msg_[message_id]_[attachment_number] are looked up
165165
# directly in the message's attachments.
166166
if pk.startswith("msg_"):
167-
message_id = pk.split("_")[1]
168-
attachment_number = pk.split("_")[2]
169-
message = models.Message.objects.get(id=message_id)
170-
171-
# Does the user have access to the message via its thread?
172-
if not models.ThreadAccess.objects.filter(
173-
thread=message.thread, mailbox__accesses__user=request.user
174-
).exists():
175-
raise models.Blob.DoesNotExist()
176-
177-
# Does the message have any attachments?
178-
if not message.has_attachments:
179-
raise models.Blob.DoesNotExist()
180-
181-
# Parse the raw mime message to get the attachment
182-
parsed_email = message.get_parsed_data()
183-
attachment = parsed_email.get("attachments", [])[int(attachment_number)]
167+
try:
168+
attachment = utils.get_attachment_from_blob_id(pk, request.user)
169+
except ValueError as e:
170+
return Response(
171+
status=status.HTTP_400_BAD_REQUEST, data={"error": str(e)}
172+
)
173+
except models.Blob.DoesNotExist as e:
174+
return Response(
175+
status=status.HTTP_404_NOT_FOUND, data={"error": str(e)}
176+
)
184177

185178
# Create response with decompressed content
186179
response = HttpResponse(

src/backend/core/api/viewsets/config.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,13 @@ class ConfigView(drf.views.APIView):
5050
"type": "string",
5151
"readOnly": True,
5252
},
53+
"file_url": {
54+
"type": "string",
55+
"readOnly": True,
56+
},
5357
},
5458
"readOnly": True,
55-
"required": ["sdk_url", "api_url"],
59+
"required": ["sdk_url", "api_url", "file_url"],
5660
},
5761
"SCHEMA_CUSTOM_ATTRIBUTES_USER": {
5862
"type": "object",
@@ -107,6 +111,7 @@ def get(self, request):
107111
"DRIVE": {
108112
"sdk_url": f"{base_url}{settings.DRIVE_CONFIG.get('sdk_url')}",
109113
"api_url": f"{base_url}{settings.DRIVE_CONFIG.get('api_url')}",
114+
"file_url": f"{base_url}{settings.DRIVE_CONFIG.get('file_url')}",
110115
}
111116
}
112117
)

0 commit comments

Comments
 (0)